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.
- package/README.md +124 -20
- package/dist/bounded.d.ts +2 -4
- package/dist/bounded.js +2 -4
- package/dist/cleanup.d.ts +8 -3
- package/dist/cleanup.js +24 -12
- package/dist/courier.d.ts +116 -11
- package/dist/courier.js +239 -55
- package/dist/group.d.ts +86 -0
- package/dist/group.js +247 -0
- package/dist/hub.d.ts +52 -77
- package/dist/hub.js +68 -65
- package/dist/index.d.ts +4 -8
- package/dist/index.js +27 -20
- package/dist/json.d.ts +4 -0
- package/dist/json.js +11 -0
- package/dist/later.d.ts +6 -6
- package/dist/later.js +6 -5
- package/dist/limits.d.ts +65 -0
- package/dist/limits.js +109 -0
- package/dist/notices.d.ts +125 -15
- package/dist/notices.js +304 -25
- package/dist/question/answer.js +6 -9
- package/dist/question/lifecycle.d.ts +10 -19
- package/dist/question/lifecycle.js +10 -19
- package/dist/question/pure.d.ts +3 -4
- package/dist/question/pure.js +2 -3
- package/dist/question/relay.js +13 -27
- package/dist/question/runtime.d.ts +2 -3
- package/dist/question/runtime.js +2 -3
- package/dist/relay.d.ts +2 -4
- package/dist/relay.js +2 -4
- package/dist/report.d.ts +48 -0
- package/dist/report.js +88 -0
- package/dist/roster.d.ts +29 -17
- package/dist/roster.js +57 -26
- package/dist/skill.d.ts +13 -0
- package/dist/skill.js +9 -0
- package/dist/stop.d.ts +41 -0
- package/dist/stop.js +120 -0
- package/dist/tools.d.ts +20 -0
- package/dist/tools.js +81 -12
- package/dist/tree.d.ts +102 -0
- package/dist/tree.js +58 -0
- package/dist/version.d.ts +4 -10
- package/dist/version.js +4 -10
- package/dist/watch.d.ts +55 -18
- package/dist/watch.js +219 -34
- package/dist/webhook.d.ts +10 -15
- package/dist/webhook.js +11 -20
- 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
|
|
9
|
-
*
|
|
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
|
|
18
|
-
*
|
|
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
|
|
5
|
-
*
|
|
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
|
|
23
|
-
*
|
|
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
|
|
27
|
-
*
|
|
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
|
|
33
|
-
*
|
|
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
|
|
46
|
-
*
|
|
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
|
|
55
|
-
* last
|
|
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 {
|
|
3
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
60
|
-
*
|
|
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
|
|
85
|
-
*
|
|
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
|
-
*
|
|
195
|
-
*
|
|
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
|
|
233
|
-
*
|
|
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
|
|
267
|
-
//
|
|
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
|
|
278
|
-
* last
|
|
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
|
|
26
|
-
*
|
|
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=`
|
|
38
|
-
*
|
|
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`
|
|
53
|
-
*
|
|
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
|
-
*
|
|
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
|
|
87
|
-
*
|
|
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 {};
|