opencode-courier 0.2.2 → 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 (60) hide show
  1. package/README.md +247 -108
  2. package/dist/bounded.d.ts +8 -0
  3. package/dist/bounded.js +19 -0
  4. package/dist/cleanup.d.ts +21 -21
  5. package/dist/cleanup.js +61 -30
  6. package/dist/courier.d.ts +116 -15
  7. package/dist/courier.js +239 -74
  8. package/dist/group.d.ts +86 -0
  9. package/dist/group.js +247 -0
  10. package/dist/hub.d.ts +300 -0
  11. package/dist/hub.js +343 -0
  12. package/dist/index.d.ts +5 -13
  13. package/dist/index.js +82 -313
  14. package/dist/json.d.ts +8 -0
  15. package/dist/json.js +15 -0
  16. package/dist/later.d.ts +7 -7
  17. package/dist/later.js +8 -7
  18. package/dist/limits.d.ts +65 -0
  19. package/dist/limits.js +109 -0
  20. package/dist/notices.d.ts +356 -0
  21. package/dist/notices.js +678 -0
  22. package/dist/question/answer.d.ts +35 -0
  23. package/dist/question/answer.js +214 -0
  24. package/dist/question/index.d.ts +6 -0
  25. package/dist/question/index.js +7 -0
  26. package/dist/question/lifecycle.d.ts +43 -0
  27. package/dist/question/lifecycle.js +91 -0
  28. package/dist/question/pure.d.ts +48 -0
  29. package/dist/question/pure.js +74 -0
  30. package/dist/question/relay.d.ts +5 -0
  31. package/dist/question/relay.js +269 -0
  32. package/dist/question/runtime.d.ts +20 -0
  33. package/dist/question/runtime.js +74 -0
  34. package/dist/question/shared.d.ts +24 -0
  35. package/dist/question/shared.js +24 -0
  36. package/dist/relay.d.ts +32 -36
  37. package/dist/relay.js +6 -56
  38. package/dist/report.d.ts +48 -0
  39. package/dist/report.js +88 -0
  40. package/dist/roster.d.ts +48 -8
  41. package/dist/roster.js +140 -17
  42. package/dist/skill.d.ts +13 -0
  43. package/dist/skill.js +9 -0
  44. package/dist/stop.d.ts +41 -0
  45. package/dist/stop.js +120 -0
  46. package/dist/storage.d.ts +8 -1
  47. package/dist/storage.js +8 -5
  48. package/dist/tools.d.ts +94 -0
  49. package/dist/tools.js +297 -0
  50. package/dist/tree.d.ts +102 -0
  51. package/dist/tree.js +58 -0
  52. package/dist/version.d.ts +4 -10
  53. package/dist/version.js +4 -10
  54. package/dist/watch.d.ts +70 -42
  55. package/dist/watch.js +346 -51
  56. package/dist/webhook.d.ts +11 -28
  57. package/dist/webhook.js +46 -146
  58. package/package.json +16 -5
  59. package/dist/question.d.ts +0 -158
  60. package/dist/question.js +0 -768
package/dist/report.js ADDED
@@ -0,0 +1,88 @@
1
+ import { obj } from "./json.js";
2
+ import { isStatus } from "./notices.js";
3
+ /**
4
+ * Whether a spawned session has reported since its last prompt, under `report/<sessionID>/`: `prompt`, `settled` (with
5
+ * the report's status), `progress` (a message to its parent without a status) and `told`; docs/reference.md has each.
6
+ */
7
+ const PREFIX = "report/";
8
+ export const promptKey = (sessionID) => `${PREFIX}${sessionID}/prompt`;
9
+ export const settledKey = (sessionID) => `${PREFIX}${sessionID}/settled`;
10
+ export const progressKey = (sessionID) => `${PREFIX}${sessionID}/progress`;
11
+ export const toldKey = (sessionID) => `${PREFIX}${sessionID}/told`;
12
+ export const stoppedKey = (sessionID) => `${PREFIX}${sessionID}/stopped`;
13
+ const timeOf = (value) => {
14
+ const at = obj(value).at;
15
+ return typeof at === "number" && Number.isFinite(at) ? at : undefined;
16
+ };
17
+ /**
18
+ * Notes that a prompt or message reached a spawned session at `at`, in epoch milliseconds, unless a
19
+ * later one is noted: both of the hub's subscriptions note each event, one of them maybe behind.
20
+ */
21
+ export async function prompted(storage, sessionID, at) {
22
+ if ((timeOf(await storage.get(promptKey(sessionID))) ?? -Infinity) < at)
23
+ await storage.set(promptKey(sessionID), { at });
24
+ }
25
+ /**
26
+ * Notes that a spawned session reported to its parent, with `status` when its report carried one, or
27
+ * its turn failed or was interrupted, at `at`, unless a later end is noted.
28
+ */
29
+ export async function settled(storage, sessionID, by, at, status) {
30
+ if ((timeOf(await storage.get(settledKey(sessionID))) ?? -Infinity) <= at)
31
+ await storage.set(settledKey(sessionID), { at, by, ...(status ? { status } : {}) });
32
+ }
33
+ /** Notes that a spawned session messaged its parent without a status at `at`, which is progress, not its report. */
34
+ export async function progressed(storage, sessionID, at) {
35
+ if ((timeOf(await storage.get(progressKey(sessionID))) ?? -Infinity) <= at)
36
+ await storage.set(progressKey(sessionID), { at });
37
+ }
38
+ /** Notes that a spawned session's parent was told, at `at`, that a turn of it ended without a report, unless a later telling is noted. */
39
+ export async function told(storage, sessionID, at) {
40
+ if ((timeOf(await storage.get(toldKey(sessionID))) ?? -Infinity) <= at)
41
+ await storage.set(toldKey(sessionID), { at });
42
+ }
43
+ /** Takes back the note `told` made at `at`, whose notice did not go out; a later one stays, and one that cannot be read goes. */
44
+ export async function untold(storage, sessionID, at) {
45
+ const stored = await storage.get(toldKey(sessionID)).then(timeOf, () => at);
46
+ if (stored === at)
47
+ await storage.remove(toldKey(sessionID));
48
+ }
49
+ /** Whether `at` is known and no earlier than the prompt at `prompt`. */
50
+ const since = (at, prompt) => at !== undefined && at >= prompt;
51
+ /** Notes that courier_stop stopped a spawned session at `at`, so the failure and silent-end notices stay quiet for it. */
52
+ export async function stopped(storage, sessionID, at) {
53
+ await storage.set(stoppedKey(sessionID), { at });
54
+ }
55
+ /** Whether courier_stop stopped a session after its last prompt, or with none noted, at all. */
56
+ export async function stoppedSincePrompt(storage, sessionID) {
57
+ const [stoppedAt, prompt] = await Promise.all([stoppedKey, promptKey].map((key) => storage.get(key(sessionID)).then(timeOf)));
58
+ return stoppedAt !== undefined && (prompt === undefined || stoppedAt >= prompt);
59
+ }
60
+ /** The status of a spawned session's last report to its parent, if it has reported with one. */
61
+ export async function lastStatus(storage, sessionID) {
62
+ const status = obj(await storage.get(settledKey(sessionID))).status;
63
+ return isStatus(status) ? status : undefined;
64
+ }
65
+ /**
66
+ * What is known of a spawned session's report: when a prompt last reached it, and whether since then it owes one, has
67
+ * messaged its parent without a status, or failed or was interrupted. Undefined for one spawned before this was kept.
68
+ */
69
+ export async function reportOf(storage, sessionID) {
70
+ const [prompt, settledValue, progress] = await Promise.all([promptKey, settledKey, progressKey].map((key) => storage.get(key(sessionID))));
71
+ const promptAt = timeOf(prompt);
72
+ if (promptAt === undefined)
73
+ return undefined;
74
+ const done = since(timeOf(settledValue), promptAt);
75
+ return { prompt: promptAt, owes: !done, progressed: since(timeOf(progress), promptAt), ended: done && obj(settledValue).by !== "report" };
76
+ }
77
+ /**
78
+ * Whether its parent still waits on a spawned session's report: it owes one, and the parent has not
79
+ * been told since its last prompt that it ended a turn without one.
80
+ */
81
+ export async function awaited(storage, sessionID) {
82
+ const [prompt, settledAt, toldAt] = await Promise.all([promptKey, settledKey, toldKey].map((key) => storage.get(key(sessionID)).then(timeOf)));
83
+ return prompt !== undefined && !since(settledAt, prompt) && !since(toldAt, prompt);
84
+ }
85
+ /** Drops what is kept about a session, with its roster entry. */
86
+ export async function forgetReport(storage, sessionID) {
87
+ await Promise.all([promptKey, settledKey, progressKey, toldKey, stoppedKey].map((key) => storage.remove(key(sessionID))));
88
+ }
package/dist/roster.d.ts CHANGED
@@ -18,10 +18,33 @@ export interface RosterEntry {
18
18
  readonly project?: string;
19
19
  /** For an isolated child, the commit its worktree was made from; its own work is what came after. */
20
20
  readonly base?: string;
21
+ /** `"status"` when its brief asked for a report with a status; absent for a child an earlier release briefed. */
22
+ readonly reports?: "status";
23
+ /** The join group courier_spawn put it in, whose release holds its report; absent for a child in none. */
24
+ readonly group?: string;
25
+ }
26
+ /**
27
+ * What the reverse index holds for a child: the sessions above it, its parent first and the top
28
+ * session, which courier_spawn did not start, last; each is the parent of the one before it.
29
+ */
30
+ export interface ReverseEntry {
31
+ readonly ancestors: ReadonlyArray<string>;
21
32
  }
22
33
  export type RosterStorage = Storage;
23
34
  export declare function rosterKey(parentID: string, sessionID: string): string;
35
+ export declare function reverseKey(sessionID: string): string;
36
+ /**
37
+ * Records a child under its parent, then indexes it. The entry is what counts: a failed index
38
+ * write leaves it to be found by a scan, and to be indexed when the plugin is next loaded.
39
+ */
24
40
  export declare function record(storage: RosterStorage, entry: RosterEntry): Promise<void>;
41
+ /**
42
+ * Removes a child's roster entry, its reverse key, its report state and, given its group, its membership. Only
43
+ * the entry's removal can fail it: a reverse key left behind leads nowhere, and the next load drops it.
44
+ */
45
+ export declare function remove(storage: RosterStorage, parentID: string, sessionID: string, group?: string): Promise<{
46
+ droppedFromGroup: boolean;
47
+ }>;
25
48
  /** Removes a child from its parent's roster; false when it was not there. */
26
49
  export declare function forget(storage: RosterStorage, parentID: string, sessionID: string): Promise<boolean>;
27
50
  /** A parent's children, oldest first. */
@@ -30,24 +53,41 @@ export declare function children(storage: RosterStorage, parentID: string): Prom
30
53
  export declare function allEntries(storage: RosterStorage): Promise<RosterEntry[]>;
31
54
  /** A session's roster entries: one for the parent that started it, none if courier_spawn did not. */
32
55
  export declare function entriesOf(storage: RosterStorage, sessionID: string): Promise<RosterEntry[]>;
56
+ /** The session that started a session, by the reverse index alone, without scanning the roster. */
57
+ export declare function indexedParent(storage: RosterStorage, sessionID: string): Promise<string | undefined>;
58
+ /** A session's roster entry under a parent, if it is there. */
59
+ export declare function entryUnder(storage: RosterStorage, parentID: string, sessionID: string): Promise<RosterEntry | undefined>;
60
+ /** A session's roster entry, by the reverse index alone, without scanning the roster. */
61
+ export declare function indexedEntry(storage: RosterStorage, sessionID: string): Promise<RosterEntry | undefined>;
33
62
  /**
34
- * The roster entries from a session up to the top, its own first: the session's, its parent's, and
35
- * so on, ending with the entry whose parent courier_spawn did not start. Empty when courier_spawn
36
- * did not start the session.
63
+ * The roster entries from a session up to the top, its own first, ending with the entry whose parent
64
+ * courier_spawn did not start; empty when courier_spawn did not start the session.
37
65
  */
38
66
  export declare function lineage(storage: RosterStorage, sessionID: string): Promise<RosterEntry[]>;
67
+ /** `lineage` by the reverse index alone: empty, without scanning the roster, when it does not name the session. */
68
+ export declare function indexedLineage(storage: RosterStorage, sessionID: string): Promise<never[] | RosterEntry[]>;
39
69
  /** `lineage` over roster entries already read. */
40
70
  export declare function lineageIn(entries: ReadonlyArray<RosterEntry>, sessionID: string): RosterEntry[];
71
+ /** Roster entries by their session, for `lineageBy`. */
72
+ export declare function bySession(entries: ReadonlyArray<RosterEntry>): Map<string, RosterEntry>;
73
+ /** `lineage` over roster entries already read and mapped by `bySession`. */
74
+ export declare function lineageBy(bySession: ReadonlyMap<string, RosterEntry>, sessionID: string): RosterEntry[];
41
75
  /**
42
- * The session at the top of a spawned session's lineage, where its permission requests and
43
- * questions go, since that is where the person is; only it may answer them. Throws when the
44
- * session was not started with courier_spawn or `callerID` is another session. `what` names
45
- * what is being answered.
76
+ * The top of a spawned session's lineage, where its permission requests and questions go, since the
77
+ * person is there. Throws unless the session was spawned and `callerID` is that top; `what` names it.
46
78
  */
47
79
  export declare function answeringTop(storage: RosterStorage, sessionID: string, callerID: string, what: string): Promise<string>;
48
80
  /** Whether a directory still exists; tests pass a fake. */
49
81
  export type Exists = (directory: string) => boolean;
50
82
  /** A parent's children, oldest first, after dropping the expired ones. */
51
83
  export declare function current(storage: RosterStorage, parentID: string, now: number, exists?: Exists): Promise<RosterEntry[]>;
52
- /** Drops expired entries of every parent, so parents that never list their children don't keep them forever. */
84
+ /**
85
+ * On loading: drops expired entries of every parent, so parents that never list their children
86
+ * don't keep them forever, then brings the reverse index in line with what is left (`backfill`).
87
+ */
53
88
  export declare function pruneExpired(storage: RosterStorage, now: number, exists?: Exists): Promise<void>;
89
+ /**
90
+ * Writes the reverse keys missing or stale for the given entries, or the whole roster, and drops those
91
+ * whose entry is gone, as an older copy's writes and removals leave them; a rerun changes nothing.
92
+ */
93
+ export declare function backfill(storage: RosterStorage, entries: ReadonlyArray<RosterEntry>): Promise<void>;
package/dist/roster.js CHANGED
@@ -1,23 +1,60 @@
1
1
  import { existsSync } from "node:fs";
2
- import { scanAll } from "./storage.js";
2
+ import { dropMember } from "./group.js";
3
+ import { forgetReport } from "./report.js";
4
+ import { scanAll, scanEntries } from "./storage.js";
3
5
  /**
4
6
  * Entries older than this are dropped when their parent's roster is read, and on plugin setup,
5
7
  * except isolated children whose worktree is still there: those stay until courier_cleanup.
6
8
  */
7
9
  export const RETENTION_MS = 14 * 24 * 60 * 60_000;
8
10
  const PREFIX = "roster/";
11
+ /**
12
+ * The reverse index: a child's `ReverseEntry` under `roster-by-child/<sessionID>`, written with its
13
+ * roster entry; only an index, so `roster/` is scanned for entries lacking one, and confirms a hit.
14
+ */
15
+ const BY_CHILD = "roster-by-child/";
9
16
  export function rosterKey(parentID, sessionID) {
10
17
  return `${PREFIX}${parentID}/${sessionID}`;
11
18
  }
19
+ export function reverseKey(sessionID) {
20
+ return `${BY_CHILD}${sessionID}`;
21
+ }
22
+ /**
23
+ * Records a child under its parent, then indexes it. The entry is what counts: a failed index
24
+ * write leaves it to be found by a scan, and to be indexed when the plugin is next loaded.
25
+ */
12
26
  export async function record(storage, entry) {
13
27
  await storage.set(rosterKey(entry.parentID, entry.sessionID), { ...entry });
28
+ await index(storage, entry).catch(() => undefined);
29
+ }
30
+ async function index(storage, entry) {
31
+ const above = await storage.get(reverseKey(entry.parentID));
32
+ const ancestors = isReverse(above) ? above.ancestors : (await lineage(storage, entry.parentID)).map((parent) => parent.parentID);
33
+ await storage.set(reverseKey(entry.sessionID), { ancestors: [entry.parentID, ...ancestors] });
34
+ }
35
+ function isReverse(value) {
36
+ const ancestors = value?.ancestors;
37
+ return Array.isArray(ancestors) && ancestors.length > 0 && ancestors.every((id) => typeof id === "string");
38
+ }
39
+ /**
40
+ * Removes a child's roster entry, its reverse key, its report state and, given its group, its membership. Only
41
+ * the entry's removal can fail it: a reverse key left behind leads nowhere, and the next load drops it.
42
+ */
43
+ export async function remove(storage, parentID, sessionID, group) {
44
+ const [, , , dropped] = await Promise.all([
45
+ storage.remove(rosterKey(parentID, sessionID)),
46
+ storage.remove(reverseKey(sessionID)).catch(() => undefined),
47
+ forgetReport(storage, sessionID).catch(() => undefined),
48
+ group ? dropMember(storage, parentID, group, sessionID).catch(() => false) : false,
49
+ ]);
50
+ return { droppedFromGroup: dropped };
14
51
  }
15
52
  /** Removes a child from its parent's roster; false when it was not there. */
16
53
  export async function forget(storage, parentID, sessionID) {
17
- const key = rosterKey(parentID, sessionID);
18
- if ((await storage.get(key)) === undefined)
54
+ const entry = (await storage.get(rosterKey(parentID, sessionID)));
55
+ if (entry === undefined)
19
56
  return false;
20
- await storage.remove(key);
57
+ await remove(storage, parentID, sessionID, entry.group);
21
58
  return true;
22
59
  }
23
60
  /** A parent's children, oldest first. */
@@ -31,29 +68,83 @@ export async function allEntries(storage) {
31
68
  }
32
69
  /** A session's roster entries: one for the parent that started it, none if courier_spawn did not. */
33
70
  export async function entriesOf(storage, sessionID) {
34
- return (await scanAll(storage, PREFIX)).filter((entry) => entry.sessionID === sessionID);
71
+ const parentID = await indexedParent(storage, sessionID);
72
+ if (parentID === undefined)
73
+ return (await scanAll(storage, PREFIX)).filter((entry) => entry.sessionID === sessionID);
74
+ const entry = await storage.get(rosterKey(parentID, sessionID));
75
+ return entry === undefined ? [] : [entry];
76
+ }
77
+ /** The session that started a session, by the reverse index alone, without scanning the roster. */
78
+ export async function indexedParent(storage, sessionID) {
79
+ const indexed = await storage.get(reverseKey(sessionID));
80
+ return isReverse(indexed) ? indexed.ancestors[0] : undefined;
81
+ }
82
+ /** A session's roster entry under a parent, if it is there. */
83
+ export async function entryUnder(storage, parentID, sessionID) {
84
+ return (await storage.get(rosterKey(parentID, sessionID)));
85
+ }
86
+ /** A session's roster entry, by the reverse index alone, without scanning the roster. */
87
+ export async function indexedEntry(storage, sessionID) {
88
+ const parentID = await indexedParent(storage, sessionID);
89
+ return parentID === undefined ? undefined : entryUnder(storage, parentID, sessionID);
35
90
  }
36
91
  /**
37
- * The roster entries from a session up to the top, its own first: the session's, its parent's, and
38
- * so on, ending with the entry whose parent courier_spawn did not start. Empty when courier_spawn
39
- * did not start the session.
92
+ * The roster entries from a session up to the top, its own first, ending with the entry whose parent
93
+ * courier_spawn did not start; empty when courier_spawn did not start the session.
40
94
  */
41
95
  export async function lineage(storage, sessionID) {
42
- return lineageIn(await allEntries(storage), sessionID);
96
+ const first = await storage.get(reverseKey(sessionID));
97
+ if (!isReverse(first))
98
+ return lineageIn(await allEntries(storage), sessionID);
99
+ return walkIndex(storage, sessionID, first);
100
+ }
101
+ /** `lineage` by the reverse index alone: empty, without scanning the roster, when it does not name the session. */
102
+ export async function indexedLineage(storage, sessionID) {
103
+ const first = await storage.get(reverseKey(sessionID));
104
+ return isReverse(first) ? walkIndex(storage, sessionID, first) : [];
105
+ }
106
+ async function walkIndex(storage, sessionID, first) {
107
+ let indexed = first;
108
+ const chain = [];
109
+ let below = sessionID;
110
+ // One read per level, all at once, and one more for the top's own reverse key, in case it was
111
+ // recorded after the key that names it; the chain ends where an entry is gone, as a scan's would.
112
+ for (;;) {
113
+ const { ancestors } = indexed;
114
+ const top = ancestors.at(-1);
115
+ const [entries, above] = await Promise.all([
116
+ Promise.all(ancestors.map((parentID, level) => storage.get(rosterKey(parentID, level ? ancestors[level - 1] : below)))),
117
+ storage.get(reverseKey(top)),
118
+ ]);
119
+ for (const entry of entries) {
120
+ if (!entry || chain.some((known) => known.sessionID === entry.sessionID))
121
+ return chain;
122
+ chain.push(entry);
123
+ }
124
+ if (!isReverse(above))
125
+ return chain;
126
+ below = top;
127
+ indexed = above;
128
+ }
43
129
  }
44
130
  /** `lineage` over roster entries already read. */
45
131
  export function lineageIn(entries, sessionID) {
46
- const bySession = new Map(entries.map((entry) => [entry.sessionID, entry]));
132
+ return lineageBy(bySession(entries), sessionID);
133
+ }
134
+ /** Roster entries by their session, for `lineageBy`. */
135
+ export function bySession(entries) {
136
+ return new Map(entries.map((entry) => [entry.sessionID, entry]));
137
+ }
138
+ /** `lineage` over roster entries already read and mapped by `bySession`. */
139
+ export function lineageBy(bySession, sessionID) {
47
140
  const chain = [];
48
141
  for (let entry = bySession.get(sessionID); entry && !chain.includes(entry); entry = bySession.get(entry.parentID))
49
142
  chain.push(entry);
50
143
  return chain;
51
144
  }
52
145
  /**
53
- * The session at the top of a spawned session's lineage, where its permission requests and
54
- * questions go, since that is where the person is; only it may answer them. Throws when the
55
- * session was not started with courier_spawn or `callerID` is another session. `what` names
56
- * what is being answered.
146
+ * The top of a spawned session's lineage, where its permission requests and questions go, since the
147
+ * person is there. Throws unless the session was spawned and `callerID` is that top; `what` names it.
57
148
  */
58
149
  export async function answeringTop(storage, sessionID, callerID, what) {
59
150
  const chain = await lineage(storage, sessionID);
@@ -70,14 +161,46 @@ export async function answeringTop(storage, sessionID, callerID, what) {
70
161
  */
71
162
  async function dropExpired(storage, entries, now, exists) {
72
163
  const expired = new Set(entries.filter((entry) => now - entry.createdAt > RETENTION_MS && !(entry.isolated && exists(entry.directory))));
73
- await Promise.all([...expired].map((entry) => storage.remove(rosterKey(entry.parentID, entry.sessionID))));
164
+ await Promise.all([...expired].map((entry) => remove(storage, entry.parentID, entry.sessionID, entry.group)));
74
165
  return entries.filter((entry) => !expired.has(entry));
75
166
  }
76
167
  /** A parent's children, oldest first, after dropping the expired ones. */
77
168
  export async function current(storage, parentID, now, exists = existsSync) {
78
169
  return dropExpired(storage, await children(storage, parentID), now, exists);
79
170
  }
80
- /** Drops expired entries of every parent, so parents that never list their children don't keep them forever. */
171
+ /**
172
+ * On loading: drops expired entries of every parent, so parents that never list their children
173
+ * don't keep them forever, then brings the reverse index in line with what is left (`backfill`).
174
+ */
81
175
  export async function pruneExpired(storage, now, exists = existsSync) {
82
- await dropExpired(storage, await scanAll(storage, PREFIX), now, exists);
176
+ await backfill(storage, await dropExpired(storage, await scanAll(storage, PREFIX), now, exists));
177
+ }
178
+ /** Whether a reverse key's value leads to this entry. */
179
+ function indexes(value, entry) {
180
+ return isReverse(value) && value.ancestors[0] === entry.parentID;
181
+ }
182
+ /**
183
+ * Writes the reverse keys missing or stale for the given entries, or the whole roster, and drops those
184
+ * whose entry is gone, as an older copy's writes and removals leave them; a rerun changes nothing.
185
+ */
186
+ export async function backfill(storage, entries) {
187
+ const indexed = new Map((await scanEntries(storage, BY_CHILD)).map(({ key, value }) => [key, value]));
188
+ const sessions = new Set(entries.map((entry) => entry.sessionID));
189
+ await Promise.all([
190
+ ...entries
191
+ .filter((entry) => !indexes(indexed.get(entry.sessionID), entry))
192
+ .map((entry) => storage.set(reverseKey(entry.sessionID), { ancestors: lineageIn(entries, entry.sessionID).map((above) => above.parentID) })),
193
+ ...[...indexed]
194
+ .filter(([sessionID]) => !sessions.has(sessionID))
195
+ .map(([sessionID, value]) => dropDangling(storage, sessionID, value)),
196
+ ]);
197
+ }
198
+ /**
199
+ * Drops a reverse key whose entry was not in the roster read, unless that entry is there now:
200
+ * another instance may have recorded the child since, and it writes the entry before the key.
201
+ */
202
+ async function dropDangling(storage, sessionID, value) {
203
+ if (isReverse(value) && (await storage.get(rosterKey(value.ancestors[0], sessionID))) !== undefined)
204
+ return;
205
+ await storage.remove(reverseKey(sessionID));
83
206
  }
@@ -0,0 +1,13 @@
1
+ import type { Plugin } from "@opencode/plugin";
2
+ /** The editor OpenCode hands a skill transform. */
3
+ export type SkillEditor = Parameters<Parameters<Plugin.Context["skill"]["transform"]>[0]>[0];
4
+ /** The courier-orchestrate skill as OpenCode lists it: its id, name and description, and the text the model loads. */
5
+ export declare const orchestrateSkill: () => {
6
+ readonly id: "courier-orchestrate";
7
+ readonly name: "courier-orchestrate";
8
+ readonly description: string;
9
+ readonly path: "/opencode-courier/courier-orchestrate.md";
10
+ readonly content: string;
11
+ };
12
+ /** Adds the courier-orchestrate skill; every instance adds the same one, under the same id. */
13
+ export declare function addSkill(skills: SkillEditor): void;
package/dist/skill.js ADDED
@@ -0,0 +1,9 @@
1
+ import { SKILL_CONTENT, SKILL_DESCRIPTION, SKILL_ID } from "./notices.js";
2
+ /** A skill's path is where OpenCode would look for its files; this one has none, so a path of the plugin's own. */
3
+ const SKILL_PATH = `/opencode-courier/${SKILL_ID}.md`;
4
+ /** The courier-orchestrate skill as OpenCode lists it: its id, name and description, and the text the model loads. */
5
+ export const orchestrateSkill = () => ({ id: SKILL_ID, name: SKILL_ID, description: SKILL_DESCRIPTION, path: SKILL_PATH, content: SKILL_CONTENT });
6
+ /** Adds the courier-orchestrate skill; every instance adds the same one, under the same id. */
7
+ export function addSkill(skills) {
8
+ skills.add(orchestrateSkill());
9
+ }
package/dist/stop.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { type CleanupPorts } from "./cleanup.js";
2
+ import { type CourierPorts } from "./courier.js";
3
+ import { type LaterPorts } from "./later.js";
4
+ import { type CleanupResult } from "./notices.js";
5
+ export interface StopPorts {
6
+ readonly courier: CourierPorts;
7
+ readonly later: LaterPorts;
8
+ readonly cleanup: CleanupPorts;
9
+ }
10
+ export interface StopInput {
11
+ readonly sessionID: string;
12
+ readonly cleanup?: boolean;
13
+ }
14
+ /** What happened to one session: its turn was interrupted, it was not running, OpenCode no longer knows it, or the interrupt failed. */
15
+ export interface Stopped {
16
+ readonly sessionID: string;
17
+ readonly title: string;
18
+ readonly depth: number;
19
+ readonly outcome: "interrupted" | "idle" | "gone" | "failed";
20
+ readonly error?: string;
21
+ }
22
+ export interface StopResult {
23
+ readonly sessionID: string;
24
+ /** Every session stopped, deepest first; the target is the last. */
25
+ readonly stopped: ReadonlyArray<Stopped>;
26
+ /** The ids of the courier_later messages cancelled. */
27
+ readonly cancelled: ReadonlyArray<string>;
28
+ /** With `cleanup`: what became of each isolated session's worktree, deepest first. */
29
+ readonly cleanup?: ReadonlyArray<CleanupResult | {
30
+ readonly sessionID: string;
31
+ readonly outcome: "failed";
32
+ readonly error: string;
33
+ }>;
34
+ /** The session told that the target was stopped, when that is not the caller. */
35
+ readonly told?: string;
36
+ }
37
+ /**
38
+ * Stops a session and everything under it, deepest first, and cancels the messages scheduled for them; only a session above the target may.
39
+ * The caller is not told; the target's parent is, unless that is the caller.
40
+ */
41
+ export declare function stop(ports: StopPorts, callerID: string, input: StopInput): Promise<StopResult>;
package/dist/stop.js ADDED
@@ -0,0 +1,120 @@
1
+ import { cleanup } from "./cleanup.js";
2
+ import { describeFailure } from "./courier.js";
3
+ import { isNotFound } from "./json.js";
4
+ import { cancel, scheduledFor } from "./later.js";
5
+ import { envelope, stoppedNotice } from "./notices.js";
6
+ import { settled, stopped } from "./report.js";
7
+ import { lineage } from "./roster.js";
8
+ import { subtree } from "./tree.js";
9
+ /** Rounds of looking again for sessions started while others were being stopped. */
10
+ const ROUNDS = 3;
11
+ /**
12
+ * Stops a session and everything under it, deepest first, and cancels the messages scheduled for them; only a session above the target may.
13
+ * The caller is not told; the target's parent is, unless that is the caller.
14
+ */
15
+ export async function stop(ports, callerID, input) {
16
+ const { storage } = ports.courier;
17
+ const { sessionID } = input;
18
+ const chain = await lineage(storage, sessionID);
19
+ if (!chain.length)
20
+ throw new Error(`${sessionID} was not started with courier_spawn, so courier_stop cannot stop it.`);
21
+ const target = chain[0];
22
+ if (!chain.some((entry) => entry.parentID === callerID))
23
+ throw new Error(`${callerID} is not above ${sessionID}: only a session above it, in the line of sessions that started it with courier_spawn, can stop it.`);
24
+ const seen = new Set();
25
+ const nodes = [];
26
+ const results = [];
27
+ const cancelled = [];
28
+ const round = async (pending, left) => {
29
+ if (!pending.length || left === 0)
30
+ return;
31
+ for (const node of pending)
32
+ seen.add(node.entry.sessionID);
33
+ nodes.push(...pending);
34
+ // Before any interrupt, so nothing scheduled wakes a session that was just stopped.
35
+ await Promise.all(pending.map((node) => silence(ports, node.entry.sessionID, cancelled)));
36
+ results.push(...(await eachDeepestFirst(pending, (node) => interrupt(ports, node))));
37
+ const meanwhile = (await subtree(storage, sessionID)).nodes.filter((node) => !seen.has(node.entry.sessionID));
38
+ return round(meanwhile, left - 1);
39
+ };
40
+ await round([{ entry: target, depth: 0 }, ...(await subtree(storage, sessionID)).nodes], ROUNDS);
41
+ const told = target.parentID !== callerID && results.find((one) => one.sessionID === sessionID)?.outcome === "interrupted"
42
+ ? await tell(ports, callerID, target)
43
+ : undefined;
44
+ return {
45
+ sessionID,
46
+ stopped: results,
47
+ cancelled,
48
+ ...(input.cleanup ? { cleanup: await removeWorktrees(ports, nodes) } : {}),
49
+ ...(told ? { told } : {}),
50
+ };
51
+ }
52
+ /** Runs `act` on the nodes level by level, the deepest level first and the level's nodes at once; the results in that order. */
53
+ function eachDeepestFirst(nodes, act) {
54
+ const depths = [...new Set(nodes.map((node) => node.depth))].sort((a, b) => b - a);
55
+ return depths.reduce((before, depth) => before.then(async (done) => [...done, ...(await Promise.all(nodes.filter((node) => node.depth === depth).map(act)))]), Promise.resolve([]));
56
+ }
57
+ /** Notes that a session was stopped, so the failure and silent-end notices stay quiet for it, and cancels its pending messages. */
58
+ async function silence(ports, sessionID, cancelled) {
59
+ const { storage, now, log } = ports.courier;
60
+ await stopped(storage, sessionID, now()).catch((error) => log(`courier_stop: could not note that ${sessionID} was stopped: ${String(error)}`));
61
+ try {
62
+ const pending = await scheduledFor(storage, sessionID);
63
+ const dropped = await Promise.all(pending.map(async (later) => ((await cancel(ports.later, later.id)) ? later.id : undefined)));
64
+ cancelled.push(...dropped.filter((id) => id !== undefined));
65
+ }
66
+ catch (error) {
67
+ log(`courier_stop: could not cancel the messages scheduled for ${sessionID}: ${String(error)}`);
68
+ }
69
+ }
70
+ async function interrupt(ports, { entry, depth }) {
71
+ const { sessionID, title } = entry;
72
+ const { storage, session, now } = ports.courier;
73
+ try {
74
+ const { interrupted } = await session.interrupt({ sessionID });
75
+ // The event for it says so too; noted here as well, so the end is settled when this returns.
76
+ if (interrupted)
77
+ await settled(storage, sessionID, "interrupted", now()).catch(() => undefined);
78
+ return { sessionID, title, depth, outcome: interrupted ? "interrupted" : "idle" };
79
+ }
80
+ catch (error) {
81
+ if (isNotFound(error))
82
+ return { sessionID, title, depth, outcome: "gone" };
83
+ return { sessionID, title, depth, outcome: "failed", error: describeFailure("session.interrupt", error).message };
84
+ }
85
+ }
86
+ /** Tells the target's parent, which is below the caller, that it was stopped; undefined when that could not be done. */
87
+ async function tell(ports, callerID, target) {
88
+ const { session, log } = ports.courier;
89
+ try {
90
+ await session.synthetic({
91
+ sessionID: target.parentID,
92
+ text: envelope(target.sessionID, stoppedNotice(target.title, callerID), { ended: "stopped" }),
93
+ description: `Session ${target.sessionID} was stopped`,
94
+ metadata: { source: "courier", from: target.sessionID, ended: "stopped" },
95
+ delivery: "steer",
96
+ });
97
+ return target.parentID;
98
+ }
99
+ catch (error) {
100
+ log(`courier_stop: could not tell ${target.parentID} that ${target.sessionID} was stopped: ${String(error)}`);
101
+ return undefined;
102
+ }
103
+ }
104
+ /** Removes the isolated sessions' worktrees, deepest first, through courier_cleanup's checks; a failure is reported, not thrown. */
105
+ async function removeWorktrees(ports, nodes) {
106
+ // One at a time: git locks the repository's worktree list while it removes one.
107
+ const removeNext = async (queue, done) => {
108
+ const [next, ...rest] = queue;
109
+ if (!next)
110
+ return done;
111
+ const { entry } = next;
112
+ const result = await cleanup(ports.cleanup, entry.parentID, { sessionID: entry.sessionID }).catch((error) => ({
113
+ sessionID: entry.sessionID,
114
+ outcome: "failed",
115
+ error: describeFailure("courier_cleanup", error).message,
116
+ }));
117
+ return removeNext(rest, [...done, result]);
118
+ };
119
+ return removeNext(nodes.filter((node) => node.entry.isolated).sort((a, b) => b.depth - a.depth), []);
120
+ }
package/dist/storage.d.ts CHANGED
@@ -3,9 +3,16 @@ type Context = Plugin.Context;
3
3
  export type Storage = Pick<Context["storage"], "get" | "set" | "remove" | "scan">;
4
4
  /** Every value stored under `prefix`, following the scan's pages. */
5
5
  export declare function scanAll<T>(storage: Pick<Storage, "scan">, prefix: string): Promise<T[]>;
6
+ /** Every key under `prefix`, without the prefix, with its value, following the scan's pages. */
7
+ export declare function scanEntries<T>(storage: Pick<Storage, "scan">, prefix: string): Promise<{
8
+ key: string;
9
+ value: T;
10
+ }[]>;
11
+ /** Where process-wide values are kept: `globalThis`, or a stand-in for it in tests. */
12
+ export type Registry = Record<symbol, unknown>;
6
13
  /**
7
14
  * The one value the whole process keeps under `Symbol.for(key)`, made by `create` the first time,
8
15
  * so every instance of the plugin, and every copy of it loaded later, gets the same one.
9
16
  */
10
- export declare function processWide<T>(key: string, create: () => T): T;
17
+ export declare function processWide<T>(key: string, create: () => T, registry?: Registry): T;
11
18
  export {};
package/dist/storage.js CHANGED
@@ -1,21 +1,24 @@
1
1
  /** Every value stored under `prefix`, following the scan's pages. */
2
2
  export async function scanAll(storage, prefix) {
3
- const values = [];
3
+ return (await scanEntries(storage, prefix)).map((entry) => entry.value);
4
+ }
5
+ /** Every key under `prefix`, without the prefix, with its value, following the scan's pages. */
6
+ export async function scanEntries(storage, prefix) {
7
+ const entries = [];
4
8
  let after;
5
9
  do {
6
10
  const page = await storage.scan({ prefix, ...(after ? { after } : {}) });
7
11
  for (const entry of page.entries)
8
- values.push(entry.value);
12
+ entries.push({ key: entry.key.slice(prefix.length), value: entry.value });
9
13
  after = page.next;
10
14
  } while (after);
11
- return values;
15
+ return entries;
12
16
  }
13
17
  /**
14
18
  * The one value the whole process keeps under `Symbol.for(key)`, made by `create` the first time,
15
19
  * so every instance of the plugin, and every copy of it loaded later, gets the same one.
16
20
  */
17
- export function processWide(key, create) {
18
- const registry = globalThis;
21
+ export function processWide(key, create, registry = globalThis) {
19
22
  const symbol = Symbol.for(key);
20
23
  registry[symbol] ??= create();
21
24
  return registry[symbol];