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/tree.js ADDED
@@ -0,0 +1,58 @@
1
+ import { described } from "./courier.js";
2
+ import { lastStatus, reportOf } from "./report.js";
3
+ import { children } from "./roster.js";
4
+ /**
5
+ * The sessions started from a session, directly or through others, read one prefix scan per parent, at most `max`
6
+ * (`truncated` if more), in the order a tree is drawn.
7
+ */
8
+ export async function subtree(storage, rootID, max = Number.POSITIVE_INFINITY) {
9
+ const byParent = new Map();
10
+ const seen = new Set([rootID]);
11
+ let truncated = false;
12
+ const expand = async (level, depth) => {
13
+ if (!level.length || truncated)
14
+ return;
15
+ const found = await Promise.all(level.map((parentID) => children(storage, parentID)));
16
+ const next = [];
17
+ for (const [index, entries] of found.entries()) {
18
+ const fresh = entries.filter((entry) => !seen.has(entry.sessionID));
19
+ const kept = fresh.slice(0, Math.max(0, max - (seen.size - 1)));
20
+ truncated ||= kept.length < fresh.length;
21
+ for (const entry of kept)
22
+ seen.add(entry.sessionID);
23
+ if (kept.length)
24
+ byParent.set(level[index], kept.map((entry) => ({ entry, depth })));
25
+ next.push(...kept.map((entry) => entry.sessionID));
26
+ }
27
+ return expand(next, depth + 1);
28
+ };
29
+ await expand([rootID], 1);
30
+ return { nodes: drawn(byParent, rootID), truncated };
31
+ }
32
+ /** The nodes in the order a tree is drawn: each followed by what it started. */
33
+ function drawn(byParent, parentID) {
34
+ return (byParent.get(parentID) ?? []).flatMap((node) => [node, ...drawn(byParent, node.entry.sessionID)]);
35
+ }
36
+ /**
37
+ * The subtree under a session, at most `maxTotal` sessions: what courier_children shows of each, plus its depth,
38
+ * its parent and its report state.
39
+ */
40
+ export async function tree(ports, rootID) {
41
+ const { nodes, truncated } = await subtree(ports.storage, rootID, ports.limits.maxTotal);
42
+ const listed = await Promise.all(nodes.map(async ({ entry, depth }) => {
43
+ const [info, report, last] = await Promise.all([
44
+ described(ports, entry),
45
+ reportOf(ports.storage, entry.sessionID).catch(() => undefined),
46
+ lastStatus(ports.storage, entry.sessionID).catch(() => undefined),
47
+ ]);
48
+ return {
49
+ ...info,
50
+ parentID: entry.parentID,
51
+ depth,
52
+ ...(report
53
+ ? { report: { owes: report.owes, progressed: report.progressed, ended: report.ended, ...(last ? { status: last } : {}) } }
54
+ : {}),
55
+ };
56
+ }));
57
+ return { sessionID: rootID, sessions: listed, ...(truncated ? { truncated: ports.limits.maxTotal } : {}) };
58
+ }
package/dist/version.d.ts CHANGED
@@ -5,18 +5,12 @@ export interface BuiltVersions {
5
5
  readonly opencode: string;
6
6
  }
7
7
  /**
8
- * Reads the versions from the package's own package.json, which sits next to `dist/` in the
9
- * published package as it does next to `src/` in the repository: `version`, and the exact
10
- * `@opencode/plugin` version under devDependencies, the one installed for the build and both test
11
- * suites; the peer dependency names the same exact version, as the copy of the plugin API that
12
- * `opencode plugin add` installs next to the plugin. Throws when the file cannot be read or does
13
- * not hold both.
8
+ * Reads from the package's own package.json, next to `dist/` as to `src/`: `version` and the exact
9
+ * `@opencode/plugin` devDependency version, which the peer repeats. Throws if either is missing.
14
10
  */
15
11
  export declare function builtVersions(): BuiltVersions;
16
12
  /**
17
- * The line for the server log when the running OpenCode is not the one the plugin was built
18
- * against; undefined when it is. Logged once per plugin load, never in a tool result. An OpenCode
19
- * whose plugin context does not report a version, as one from before the pinned API or after a
20
- * change to it may not, is the case the line is for, so it still names the pin then.
13
+ * The server-log line for an OpenCode other than the plugin was built against, undefined when it is;
14
+ * logged once per load, never in a tool result. A context without a version is still told the pin.
21
15
  */
22
16
  export declare function versionNotice(built: BuiltVersions, app: Partial<Pick<App, "version" | "channel">> | undefined): string | undefined;
package/dist/version.js CHANGED
@@ -1,12 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  const README_TABLE = "https://github.com/ivopogace/opencode-courier#supported-opencode-version";
3
3
  /**
4
- * Reads the versions from the package's own package.json, which sits next to `dist/` in the
5
- * published package as it does next to `src/` in the repository: `version`, and the exact
6
- * `@opencode/plugin` version under devDependencies, the one installed for the build and both test
7
- * suites; the peer dependency names the same exact version, as the copy of the plugin API that
8
- * `opencode plugin add` installs next to the plugin. Throws when the file cannot be read or does
9
- * not hold both.
4
+ * Reads from the package's own package.json, next to `dist/` as to `src/`: `version` and the exact
5
+ * `@opencode/plugin` devDependency version, which the peer repeats. Throws if either is missing.
10
6
  */
11
7
  export function builtVersions() {
12
8
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -19,10 +15,8 @@ export function builtVersions() {
19
15
  return { plugin, opencode };
20
16
  }
21
17
  /**
22
- * The line for the server log when the running OpenCode is not the one the plugin was built
23
- * against; undefined when it is. Logged once per plugin load, never in a tool result. An OpenCode
24
- * whose plugin context does not report a version, as one from before the pinned API or after a
25
- * change to it may not, is the case the line is for, so it still names the pin then.
18
+ * The server-log line for an OpenCode other than the plugin was built against, undefined when it is;
19
+ * logged once per load, never in a tool result. A context without a version is still told the pin.
26
20
  */
27
21
  export function versionNotice(built, app) {
28
22
  const version = typeof app?.version === "string" ? app.version : undefined;
package/dist/watch.d.ts CHANGED
@@ -7,31 +7,76 @@ export declare const RESUBSCRIBE_MS = 5000;
7
7
  /** The part of OpenCode's `session.execution.failed` event the notice is made from. */
8
8
  export interface ExecutionFailed {
9
9
  readonly id: string;
10
+ /** When OpenCode published it, in epoch milliseconds. */
11
+ readonly created?: number;
10
12
  readonly data: {
11
13
  readonly sessionID: string;
12
14
  readonly error: ExecutionError;
13
15
  };
14
16
  }
17
+ /** OpenCode's events that concern one session's turns and inbox: `session.execution.succeeded` and the like. */
18
+ export interface SessionEvent {
19
+ readonly id: string;
20
+ readonly created?: number;
21
+ readonly data: {
22
+ readonly sessionID: string;
23
+ readonly reason?: string;
24
+ };
25
+ }
15
26
  /**
16
27
  * Tells the parent of a child whose turn failed, since a child that cannot reach its model cannot
17
28
  * call courier_send. Returns the parents told.
18
29
  */
19
30
  export declare function reportFailure(ports: WatchPorts, seen: Set<string>, event: ExecutionFailed): Promise<string[]>;
31
+ /** OpenCode's `session.inbox.enqueued`: the item's type, which its `session.inbox.delivered` does not carry. */
32
+ export interface InboxEnqueued {
33
+ readonly data: {
34
+ readonly sessionID: string;
35
+ readonly inboxID: string;
36
+ readonly item: {
37
+ readonly type: string;
38
+ };
39
+ };
40
+ }
41
+ /** Remembers the type of an item enqueued for a spawned session, for `notePrompt`; other sessions cost one read. */
42
+ export declare function noteEnqueued(ports: WatchPorts, inbox: Map<string, string>, event: InboxEnqueued): Promise<boolean>;
43
+ /**
44
+ * Notes that a message reached a spawned session, which then owes its parent a report, unless `inbox` knows it for one of
45
+ * `UNOWED` (`spawn` notes the task itself); reads the reverse index alone, and claims nothing: the note is the same twice.
46
+ */
47
+ export declare function notePrompt(ports: WatchPorts, event: SessionEvent & {
48
+ readonly data: {
49
+ readonly inboxID?: string;
50
+ };
51
+ }, inbox?: Map<string, string>): Promise<boolean>;
52
+ /**
53
+ * Notes that a spawned session's turn was stopped, which takes it out of its group without a report; one
54
+ * stopped by a shutdown resumes on the next start.
55
+ */
56
+ export declare function noteInterrupted(ports: WatchPorts, event: SessionEvent): Promise<boolean>;
57
+ /**
58
+ * Drops the report state, and the groups, of a session OpenCode deleted, and the report state of the sessions it
59
+ * started, whose reports can no longer be delivered; it leaves its own group. Returns the sessions forgotten.
60
+ */
61
+ export declare function noteDeleted(ports: WatchPorts, event: SessionEvent): Promise<string[]>;
62
+ /**
63
+ * Tells the parent of a spawned session whose turn ended without reporting to it since its last
64
+ * prompt, unless it waits; finds the session by the reverse index alone. Returns the parents told.
65
+ */
66
+ export declare function reportSilent(ports: WatchPorts, seen: Set<string>, event: SessionEvent): Promise<string[]>;
20
67
  /**
21
68
  * Tells the session at the top, the parent of a child or the session a chain of children started
22
69
  * from, what a waiting child asks and which answers there are, waking it. Returns the sessions told.
23
70
  */
24
71
  export declare function reportAsked(ports: WatchPorts, state: WatchState, event: PermissionAsked): Promise<string[]>;
25
72
  /**
26
- * Tells the session that was told about a request that it has been answered some other way, in the
27
- * child's own session or along with another answer, so it does not pass on a stale question.
28
- * Requests answered through courier_answer are no longer waiting and are skipped.
73
+ * Tells the session told about a request that it was answered some other way, so it does not pass on
74
+ * a stale question; requests answered through courier_answer no longer wait and are skipped.
29
75
  */
30
76
  export declare function reportReplied(ports: WatchPorts, state: WatchState, event: PermissionReplied): Promise<string[]>;
31
77
  /**
32
- * Tells the session at the top about a form OpenCode shows in a spawned session, such as web
33
- * search asking for its provider, which only the person can answer there. Question forms are
34
- * left to the question relay, which passes every question of a spawned session on.
78
+ * Tells the top session about a form OpenCode shows in a spawned session, such as web search asking
79
+ * for its provider, which only the person can answer; question forms are left to the question relay.
35
80
  */
36
81
  export declare function reportForm(ports: WatchPorts, state: WatchState, event: FormCreated): Promise<string[]>;
37
82
  /** Tells the session told about a form that it was answered or withdrawn, so it does not send the person to a form that is gone. */
@@ -42,20 +87,12 @@ export declare function reportFormSettled(ports: WatchPorts, state: WatchState,
42
87
  */
43
88
  export declare function relayPending(ports: WatchPorts, state: WatchState): Promise<void>;
44
89
  /**
45
- * Follows OpenCode's events until `signal` aborts, telling parents when a spawned child's turn
46
- * fails, when it waits for a permission or on a form and when that is answered without them, and
47
- * noting for the question relay the question forms shown, which it waits for, and the locations
48
- * shutting down, whose withdrawn forms must not pass for dismissals. Once `signal` aborts it ends
49
- * as soon as the event it is handling, and the relay of the requests already pending, are done,
50
- * without waiting for the stream to close: an unloading instance's leave waits for that.
90
+ * Follows OpenCode's events until `signal` aborts: tells parents of a spawned child's failed or silent
91
+ * turn, its pending permission or form, notes forms and shutdowns for the relay; ends after its current event.
51
92
  */
52
93
  export declare function watchChildren(ports: WatchPorts, state: WatchState, signal: AbortSignal, retryMs?: number): Promise<void>;
53
94
  /**
54
- * Makes the hub start its subscriptions to OpenCode's events with this copy's watcher, the copy loaded
55
- * last's, as after an update; `join` starts them. Every instance in the process is sent every event,
56
- * so one subscription would serve them all; the second is a standby, already connected when the
57
- * first one's member leaves, so no event goes unseen then. Both handle every event, and the claim
58
- * sets tell each once. Like any (re)subscription, a new one relays the requests already pending,
59
- * which the claim sets tell once too.
95
+ * Makes the hub start its subscriptions to OpenCode's events with this copy's watcher, the one loaded
96
+ * last; `join` starts them. The second is a standby, connected before the first's member leaves.
60
97
  */
61
98
  export declare function watchFromHub(hub: Pick<Hub, "subscribe">, state: WatchState, retryMs?: number): void;
package/dist/watch.js CHANGED
@@ -1,8 +1,13 @@
1
1
  import { addBounded, setBounded } from "./bounded.js";
2
- import { envelope, failureNotice, formNotice, formSettledNotice, kindOf, permissionNotice, permissionSettledNotice, } from "./notices.js";
3
- import { eventsFollowed, eventsLeft, formShown, formsMayHaveBeenMissed, locationClosing } from "./question/index.js";
2
+ import { lastReply } from "./courier.js";
3
+ import { dropGroups, hasCompleteGroup, markLeft, membershipOf } from "./group.js";
4
+ import { envelope, failureNotice, formNotice, formSettledNotice, kindOf, permissionNotice, permissionSettledNotice, silentNotice, } from "./notices.js";
5
+ import { eventsFollowed, eventsLeft, formShown, formsMayHaveBeenMissed, locationClosing, pendingQuestions } from "./question/index.js";
4
6
  import { listEverywhere, QUESTION_FORM, } from "./relay.js";
5
- import { allEntries, entriesOf, lineage } from "./roster.js";
7
+ import { scheduledFor } from "./later.js";
8
+ import { awaited, forgetReport, prompted, reportOf, settled, stoppedSincePrompt, told, untold } from "./report.js";
9
+ import { allEntries, children, entriesOf, entryUnder, indexedEntry, indexedParent, lineage } from "./roster.js";
10
+ import { subscriptions } from "./webhook.js";
6
11
  /** How long to wait before subscribing again after the event stream ended or broke. */
7
12
  export const RESUBSCRIBE_MS = 5_000;
8
13
  /** How many handled event ids, and waiting or answered requests, are remembered. */
@@ -18,15 +23,174 @@ export async function reportFailure(ports, seen, event) {
18
23
  return [];
19
24
  const { sessionID, error } = event.data;
20
25
  const entries = await entriesOf(ports.storage, sessionID);
21
- await Promise.all(entries.map((entry) => ports.session.synthetic({
22
- sessionID: entry.parentID,
23
- text: envelope(sessionID, failureNotice(entry.title, error), { failed: error.type }),
24
- description: `Session ${sessionID} failed`,
25
- metadata: { source: "courier", from: sessionID, failed: true },
26
- delivery: "steer",
27
- })));
26
+ const at = event.created ?? ports.now();
27
+ // Not in the way of the notice: kept only so the limits stop counting the child.
28
+ if (entries.length)
29
+ await settled(ports.storage, sessionID, "failed", at).catch((error) => ports.log(`courier watch: could not note the failed turn of ${sessionID}: ${String(error)}`));
30
+ const out = await Promise.all(entries.map((entry) => outIn(ports, entry)));
31
+ // Left before the notice, so the parent waits on the group meanwhile; nudged after it, so the group's
32
+ // message does not overtake the notice, short of an interval tick falling in between.
33
+ const completed = await Promise.all(out.map((member) => (member ? leave(ports, member, "failed", at) : Promise.resolve(false))));
34
+ // A session courier_stop stopped is not a failure to tell: its parent was told of the stop, or ordered it.
35
+ const quiet = entries.length > 0 && (await stoppedSincePrompt(ports.storage, sessionID).catch(() => false));
36
+ try {
37
+ if (quiet)
38
+ return [];
39
+ await Promise.all(entries.map((entry, index) => ports.session.synthetic({
40
+ sessionID: entry.parentID,
41
+ text: envelope(sessionID, failureNotice(entry.title, error, out[index]?.group), { failed: error.type }),
42
+ description: `Session ${sessionID} failed`,
43
+ metadata: { source: "courier", from: sessionID, failed: true },
44
+ delivery: "steer",
45
+ })));
46
+ }
47
+ finally {
48
+ if (completed.includes(true))
49
+ ports.nudge();
50
+ }
28
51
  return entries.map((entry) => entry.parentID);
29
52
  }
53
+ /** A child's membership of an open group it is still out in: the group waits for it, and its end takes it out without a report. */
54
+ async function outIn(ports, entry) {
55
+ if (!entry.group)
56
+ return undefined;
57
+ try {
58
+ const member = await membershipOf(ports.storage, entry.parentID, entry.group, entry.sessionID);
59
+ return member?.out ? member : undefined;
60
+ }
61
+ catch (error) {
62
+ ports.log(`courier watch: could not read ${entry.sessionID}'s place in group ${entry.group}: ${String(error)}`);
63
+ return undefined;
64
+ }
65
+ }
66
+ /** Takes a member out of its group without a report; true when that completes the group, for the caller to nudge the scheduler. */
67
+ async function leave(ports, member, by, at) {
68
+ try {
69
+ return await markLeft(ports.storage, member, by, at);
70
+ }
71
+ catch (error) {
72
+ ports.log(`courier watch: could not take ${member.sessionID} out of group ${member.group}: ${String(error)}`);
73
+ return false;
74
+ }
75
+ }
76
+ /** Inbox items that are not courier's or the parent's business: the person's own prompts, compactions and moves. */
77
+ const UNOWED = new Set(["user", "compaction", "move"]);
78
+ /** Remembers the type of an item enqueued for a spawned session, for `notePrompt`; other sessions cost one read. */
79
+ export async function noteEnqueued(ports, inbox, event) {
80
+ const { sessionID, inboxID, item } = event.data;
81
+ if ((await indexedParent(ports.storage, sessionID)) === undefined)
82
+ return false;
83
+ setBounded(inbox, inboxID, item.type, SEEN_MAX);
84
+ return true;
85
+ }
86
+ /**
87
+ * Notes that a message reached a spawned session, which then owes its parent a report, unless `inbox` knows it for one of
88
+ * `UNOWED` (`spawn` notes the task itself); reads the reverse index alone, and claims nothing: the note is the same twice.
89
+ */
90
+ export async function notePrompt(ports, event, inbox) {
91
+ const { sessionID, inboxID } = event.data;
92
+ // Kept, not deleted: the hub's other subscription looks it up for the same delivery.
93
+ if (inboxID !== undefined && UNOWED.has(inbox?.get(inboxID) ?? ""))
94
+ return false;
95
+ if ((await indexedParent(ports.storage, sessionID)) === undefined)
96
+ return false;
97
+ await prompted(ports.storage, sessionID, event.created ?? ports.now());
98
+ return true;
99
+ }
100
+ /**
101
+ * Notes that a spawned session's turn was stopped, which takes it out of its group without a report; one
102
+ * stopped by a shutdown resumes on the next start.
103
+ */
104
+ export async function noteInterrupted(ports, event) {
105
+ if (event.data.reason === "shutdown")
106
+ return false;
107
+ const { sessionID } = event.data;
108
+ const entry = await indexedEntry(ports.storage, sessionID);
109
+ if (entry === undefined)
110
+ return false;
111
+ const at = event.created ?? ports.now();
112
+ await settled(ports.storage, sessionID, "interrupted", at);
113
+ const member = await outIn(ports, entry);
114
+ if (member && (await leave(ports, member, "interrupted", at)))
115
+ ports.nudge();
116
+ return true;
117
+ }
118
+ /**
119
+ * Drops the report state, and the groups, of a session OpenCode deleted, and the report state of the sessions it
120
+ * started, whose reports can no longer be delivered; it leaves its own group. Returns the sessions forgotten.
121
+ */
122
+ export async function noteDeleted(ports, event) {
123
+ const { sessionID } = event.data;
124
+ const [parentID, started] = await Promise.all([indexedParent(ports.storage, sessionID), children(ports.storage, sessionID)]);
125
+ const forgotten = [...(parentID === undefined ? [] : [sessionID]), ...started.map((child) => child.sessionID)];
126
+ const entry = parentID === undefined ? undefined : await entryUnder(ports.storage, parentID, sessionID);
127
+ const member = entry && (await outIn(ports, entry));
128
+ // Its groups go whether or not it has children on the roster: a held report outlives the member's entry.
129
+ const [completed] = await Promise.all([
130
+ member ? leave(ports, member, "deleted", event.created ?? ports.now()) : Promise.resolve(false),
131
+ ...forgotten.map((id) => forgetReport(ports.storage, id)),
132
+ dropGroups(ports.storage, sessionID),
133
+ ]);
134
+ if (completed)
135
+ ports.nudge();
136
+ return forgotten;
137
+ }
138
+ /**
139
+ * Whether a session prompted at `prompt` waits: on an untold report of a session it started, a complete group of
140
+ * its about to be delivered, a request of its own, a scheduled message, or a webhook it subscribed to since. Cheapest first.
141
+ */
142
+ async function waits(ports, sessionID, prompt) {
143
+ const started = await children(ports.storage, sessionID);
144
+ if ((await Promise.all(started.map((entry) => awaited(ports.storage, entry.sessionID)))).includes(true))
145
+ return true;
146
+ if ((await listEverywhere([...ports.permissions()], sessionID)).some((found) => found.requests.length))
147
+ return true;
148
+ // A group with a member still out is waited on through that member, above; one whose every out member was told of is not.
149
+ if (started.some((entry) => entry.group) && (await hasCompleteGroup(ports.storage, sessionID)))
150
+ return true;
151
+ if ((await pendingQuestions(ports.storage, sessionID)).length)
152
+ return true;
153
+ const [scheduled, subscribed] = await Promise.all([scheduledFor(ports.storage, sessionID), subscriptions(ports)]);
154
+ // An older subscription is no wait: a prompt, most likely its delivery, has come since.
155
+ return scheduled.length > 0 || subscribed.some((subscription) => subscription.sessionID === sessionID && subscription.createdAt >= prompt);
156
+ }
157
+ /**
158
+ * Tells the parent of a spawned session whose turn ended without reporting to it since its last
159
+ * prompt, unless it waits; finds the session by the reverse index alone. Returns the parents told.
160
+ */
161
+ export async function reportSilent(ports, seen, event) {
162
+ const { sessionID } = event.data;
163
+ const entry = await indexedEntry(ports.storage, sessionID);
164
+ // Claimed after the lookup, so other sessions' turns do not crowd `seen`, and before anything is told.
165
+ if (!entry || !claim(seen, event.id))
166
+ return [];
167
+ const report = await reportOf(ports.storage, sessionID);
168
+ if (!report?.owes || (await waits(ports, sessionID, report.prompt)))
169
+ return [];
170
+ if (await stoppedSincePrompt(ports.storage, sessionID).catch(() => false))
171
+ return [];
172
+ // The notice goes out without the reply rather than not at all.
173
+ const lastText = await ports.session.context({ sessionID }).then(lastReply, () => undefined);
174
+ const group = (await outIn(ports, entry))?.group;
175
+ // Before the notice, which may end the parent's turn at once: once told, it no longer waits for this one.
176
+ const toldAt = event.created ?? ports.now();
177
+ await told(ports.storage, sessionID, toldAt).catch((error) => ports.log(`courier watch: could not note that the parent of ${sessionID} was told: ${String(error)}`));
178
+ try {
179
+ await ports.session.synthetic({
180
+ sessionID: entry.parentID,
181
+ text: envelope(sessionID, silentNotice(entry.title, lastText, report.progressed, group), { ended: "without-report" }),
182
+ description: `Session ${sessionID} ended without a report`,
183
+ metadata: { source: "courier", from: sessionID, ended: "without-report" },
184
+ delivery: "steer",
185
+ });
186
+ }
187
+ catch (error) {
188
+ // Not told after all: the parent still waits for it.
189
+ await untold(ports.storage, sessionID, toldAt).catch(() => undefined);
190
+ throw error;
191
+ }
192
+ return [entry.parentID];
193
+ }
30
194
  /** The session at the top of a lineage: where its permission requests go, since that is where the person is. */
31
195
  const topOf = (chain) => chain.at(-1).parentID;
32
196
  /**
@@ -56,9 +220,8 @@ export async function reportAsked(ports, state, event) {
56
220
  return [topOf(chain)];
57
221
  }
58
222
  /**
59
- * Tells the session that was told about a request that it has been answered some other way, in the
60
- * child's own session or along with another answer, so it does not pass on a stale question.
61
- * Requests answered through courier_answer are no longer waiting and are skipped.
223
+ * Tells the session told about a request that it was answered some other way, so it does not pass on
224
+ * a stale question; requests answered through courier_answer no longer wait and are skipped.
62
225
  */
63
226
  export async function reportReplied(ports, state, event) {
64
227
  if (!claim(state.seen, event.id))
@@ -81,9 +244,8 @@ export async function reportReplied(ports, state, event) {
81
244
  return [topOf(chain)];
82
245
  }
83
246
  /**
84
- * Tells the session at the top about a form OpenCode shows in a spawned session, such as web
85
- * search asking for its provider, which only the person can answer there. Question forms are
86
- * left to the question relay, which passes every question of a spawned session on.
247
+ * Tells the top session about a form OpenCode shows in a spawned session, such as web search asking
248
+ * for its provider, which only the person can answer; question forms are left to the question relay.
87
249
  */
88
250
  export async function reportForm(ports, state, event) {
89
251
  if (!claim(state.seen, event.id))
@@ -162,13 +324,46 @@ const pause = (ms, signal) => new Promise((resolve) => {
162
324
  }
163
325
  signal.addEventListener("abort", done);
164
326
  });
327
+ const TURN_ENDS = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"]);
328
+ /** A session's turn ended, sealing its groups: one that is complete has the scheduler deliver it now. */
329
+ async function sealGroups(ports, event) {
330
+ if (event.data.reason === "shutdown")
331
+ return;
332
+ try {
333
+ if (await hasCompleteGroup(ports.storage, event.data.sessionID))
334
+ ports.nudge();
335
+ }
336
+ catch (error) {
337
+ ports.log(`courier watch: could not read the groups of ${event.data.sessionID}: ${String(error)}`);
338
+ }
339
+ }
165
340
  /**
166
341
  * Handles one event of OpenCode's stream; those that cannot concern a spawned child, or a question
167
342
  * relayed for one, are ignored.
168
343
  */
169
344
  async function handle(ports, state, event) {
345
+ try {
346
+ return await route(ports, state, event);
347
+ }
348
+ finally {
349
+ // After: a silent end is judged while the group the nudge may deliver is still there to wait on.
350
+ if (TURN_ENDS.has(event.type))
351
+ await sealGroups(ports, event);
352
+ }
353
+ }
354
+ async function route(ports, state, event) {
170
355
  if (event.type === "session.execution.failed")
171
356
  return reportFailure(ports, state.seen, event);
357
+ if (event.type === "session.execution.succeeded")
358
+ return reportSilent(ports, state.seen, event);
359
+ if (event.type === "session.execution.interrupted")
360
+ return noteInterrupted(ports, event);
361
+ if (event.type === "session.inbox.enqueued")
362
+ return noteEnqueued(ports, state.inbox, event);
363
+ if (event.type === "session.inbox.delivered")
364
+ return notePrompt(ports, event, state.inbox);
365
+ if (event.type === "session.deleted")
366
+ return noteDeleted(ports, event);
172
367
  if (event.type === "permission.asked")
173
368
  return reportAsked(ports, state, event);
174
369
  if (event.type === "permission.replied")
@@ -191,9 +386,8 @@ async function handle(ports, state, event) {
191
386
  }
192
387
  const ENDED = { done: true, value: undefined };
193
388
  /**
194
- * The events of `events` until it ends or `signal` aborts, whichever comes first; the stream is not
195
- * closed. Each wait for the next event listens for the abort only while it waits, so a watcher that
196
- * follows events for the life of the process holds nothing per event it has seen.
389
+ * `events` until it ends or `signal` aborts, the stream left open; each wait hears the abort only while
390
+ * it waits, so a watcher that follows events for the life of the process holds nothing per event.
197
391
  */
198
392
  const until = (events, signal) => ({
199
393
  [Symbol.asyncIterator]: () => ({
@@ -229,12 +423,8 @@ function handleLogged(ports, state, event) {
229
423
  });
230
424
  }
231
425
  /**
232
- * Follows OpenCode's events until `signal` aborts, telling parents when a spawned child's turn
233
- * fails, when it waits for a permission or on a form and when that is answered without them, and
234
- * noting for the question relay the question forms shown, which it waits for, and the locations
235
- * shutting down, whose withdrawn forms must not pass for dismissals. Once `signal` aborts it ends
236
- * as soon as the event it is handling, and the relay of the requests already pending, are done,
237
- * without waiting for the stream to close: an unloading instance's leave waits for that.
426
+ * Follows OpenCode's events until `signal` aborts: tells parents of a spawned child's failed or silent
427
+ * turn, its pending permission or form, notes forms and shutdowns for the relay; ends after its current event.
238
428
  */
239
429
  export async function watchChildren(ports, state, signal, retryMs = RESUBSCRIBE_MS) {
240
430
  const relaying = new Set();
@@ -263,9 +453,8 @@ export async function watchChildren(ports, state, signal, retryMs = RESUBSCRIBE_
263
453
  finally {
264
454
  if (following)
265
455
  eventsLeft();
266
- // Closed without waiting: the subscription's own signal has aborted, or the stream ended, and
267
- // closing the adapter's stream waits on OpenCode's side, which may not answer while a location
268
- // closes; the leave that waits for this watcher must not wait for that too.
456
+ // Closed without waiting: closing the adapter's stream waits on OpenCode, which may not answer
457
+ // while a location closes, and the leave waiting for this watcher must not wait for that too.
269
458
  void events?.return?.().catch(() => { });
270
459
  }
271
460
  if (!signal.aborted)
@@ -274,12 +463,8 @@ export async function watchChildren(ports, state, signal, retryMs = RESUBSCRIBE_
274
463
  await Promise.allSettled(relaying);
275
464
  }
276
465
  /**
277
- * Makes the hub start its subscriptions to OpenCode's events with this copy's watcher, the copy loaded
278
- * last's, as after an update; `join` starts them. Every instance in the process is sent every event,
279
- * so one subscription would serve them all; the second is a standby, already connected when the
280
- * first one's member leaves, so no event goes unseen then. Both handle every event, and the claim
281
- * sets tell each once. Like any (re)subscription, a new one relays the requests already pending,
282
- * which the claim sets tell once too.
466
+ * Makes the hub start its subscriptions to OpenCode's events with this copy's watcher, the one loaded
467
+ * last; `join` starts them. The second is a standby, connected before the first's member leaves.
283
468
  */
284
469
  export function watchFromHub(hub, state, retryMs = RESUBSCRIBE_MS) {
285
470
  hub.subscribe = (member) => {
package/dist/webhook.d.ts CHANGED
@@ -22,9 +22,8 @@ export interface WebhookConfig {
22
22
  readonly maxBytes: number;
23
23
  }
24
24
  /**
25
- * Normalises a topic: `owner/repo` or `owner/repo#12` (optionally prefixed `github:`) is a GitHub
26
- * repository or one of its pull requests or issues; any other name of letters, digits, `.`, `_` and
27
- * `-` is a generic topic, posted to `/hook/<name>`.
25
+ * Normalises a topic: `owner/repo` or `owner/repo#12` (optionally `github:`-prefixed) is a GitHub
26
+ * repo, PR or issue; any other name of letters, digits, `.`, `_` and `-` is posted to `/hook/<name>`.
28
27
  */
29
28
  export declare function parseTopic(input: string): string;
30
29
  /** Subscribes an existing session to a topic; throws for a session OpenCode does not know. */
@@ -34,9 +33,8 @@ export declare function unsubscribe(ports: WebhookPorts, sessionID: string, topi
34
33
  /** Every subscription, or those to one topic. */
35
34
  export declare function subscriptions(ports: Pick<WebhookPorts, "storage">, topic?: string): Promise<Subscription[]>;
36
35
  /**
37
- * Checks an `X-Hub-Signature-256` header (`sha256=` and exactly 64 hex digits) in constant time and
38
- * returns the digest in canonical lowercase hex, or undefined when it does not match. Replays are
39
- * keyed by that digest, never by the header text, which could be re-cased or padded.
36
+ * Checks an `X-Hub-Signature-256` header (`sha256=` + 64 hex digits) in constant time, returning the
37
+ * digest in lowercase hex, or undefined; replays are keyed by that digest, never by the header text.
40
38
  */
41
39
  export declare function checkSignature(secret: string, body: Uint8Array, header: string | undefined, name?: string): string | undefined;
42
40
  /** The `X-Hub-Signature-256` value for a body; give the topic name for `/hook/<name>`. */
@@ -49,9 +47,8 @@ export interface Event {
49
47
  readonly summary: string;
50
48
  }
51
49
  /**
52
- * Maps a GitHub delivery to its topics (`github:owner/repo` and, for pull requests and issues,
53
- * `github:owner/repo#N`) and a summary. Undefined for deliveries nobody should be woken for: pings,
54
- * CI events that have not completed, and pull request or issue actions outside `ITEM_ACTIONS`.
50
+ * Maps a GitHub delivery to its topics (`github:owner/repo`, plus `github:owner/repo#N` for PRs and
51
+ * issues) and a summary; undefined for pings, unfinished CI events and actions outside `ITEM_ACTIONS`.
55
52
  */
56
53
  export declare function githubEvent(name: string, payload: unknown): Event | undefined;
57
54
  /** A delivery to `/hook/<topic>`: a JSON body's `text`, `summary` or `message` field, else the body itself. */
@@ -75,17 +72,15 @@ export interface Response {
75
72
  readonly body: string;
76
73
  }
77
74
  /**
78
- * Handles one request: `POST /github` with GitHub's headers, or `POST /hook/<name>` signed over
79
- * the name and the body. Anything not signed with the secret is refused before its body is parsed,
80
- * and a signature already accepted is ignored, so a captured delivery cannot be replayed.
75
+ * Handles one request: `POST /github` with GitHub's headers, or `POST /hook/<name>` signed over name
76
+ * and body; anything unsigned is refused before parsing, and a signature already accepted is ignored.
81
77
  */
82
78
  export declare function receive(ports: WebhookPorts, secret: string, request: Request, seen?: Set<string>): Promise<Response>;
83
79
  /** Starts the HTTP receiver; `ports()` picks a live plugin instance for each request. */
84
80
  export declare function listen(config: WebhookConfig, ports: () => WebhookPorts | undefined): Promise<Server<typeof IncomingMessage, typeof import("node:http").ServerResponse>>;
85
81
  /**
86
- * Reads the plugin's `webhook` option. The secret comes from the file named by `secretFile`, or
87
- * else the environment variable named by `secretEnv` (default `COURIER_WEBHOOK_SECRET`); never from
88
- * the option itself, so it stays out of opencode.json. Undefined when the receiver is not configured.
82
+ * Reads the `webhook` option, undefined when the receiver is not configured; the secret comes from
83
+ * `secretFile` or the env var `secretEnv` (default `COURIER_WEBHOOK_SECRET`), never from the option.
89
84
  */
90
85
  export declare function readConfig(options: unknown, env?: Record<string, string | undefined>): WebhookConfig | undefined;
91
86
  export {};