opencode-courier 0.1.6

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.
@@ -0,0 +1,76 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ import { type Pending } from "./courier.js";
3
+ import { type RosterStorage } from "./roster.js";
4
+ type Context = Plugin.Context;
5
+ /** One location's pending permission requests. OpenCode keeps them per location, so an isolated child's are in its worktree's. */
6
+ export type Permissions = Pick<Context["permission"], "list" | "reply">;
7
+ /** The answers to a permission request, as OpenCode's own prompt offers them. */
8
+ export declare const REPLIES: readonly ["once", "always", "reject"];
9
+ export type Reply = (typeof REPLIES)[number];
10
+ /**
11
+ * Sent with a rejection that has no reason of its own. OpenCode ends the child's turn on a bare
12
+ * rejection, and the child would never report back; with a message, the call fails and it carries on.
13
+ */
14
+ export declare const REJECTED = "Refused by the session that started you. Do without it, or report back why you cannot.";
15
+ /** The part of OpenCode's `permission.asked` event the notice is made from. */
16
+ export interface PermissionAsked {
17
+ readonly id: string;
18
+ readonly data: {
19
+ readonly id: string;
20
+ readonly sessionID: string;
21
+ readonly action: string;
22
+ readonly resources: ReadonlyArray<string>;
23
+ readonly save?: ReadonlyArray<string> | undefined;
24
+ readonly message?: string | undefined;
25
+ };
26
+ }
27
+ /** The part of OpenCode's `permission.replied` event. */
28
+ export interface PermissionReplied {
29
+ readonly id: string;
30
+ readonly data: {
31
+ readonly sessionID: string;
32
+ readonly requestID: string;
33
+ readonly reply: Reply;
34
+ };
35
+ }
36
+ /**
37
+ * Shared by every plugin instance in the process, like the handled event ids: the requests whose
38
+ * notice went to a parent and that courier_answer has not answered yet.
39
+ */
40
+ export type Waiting = Set<string>;
41
+ /** How a notice names where the asking session came from, to the session at the top of its lineage. */
42
+ export declare const origin: (startedBy?: string) => string;
43
+ /** For a notice that a session's turn ended without it finishing. */
44
+ export declare const STAYS_QUIET = "and then it does not report back on its own: if it stays quiet, message it with courier_send to have it carry on.";
45
+ /**
46
+ * What the session at the top is told. `startedBy` names the session that started the asking one
47
+ * when that is not the top session itself, but one started from it.
48
+ */
49
+ export declare function permissionNotice(title: string, request: PermissionAsked["data"], startedBy?: string): string;
50
+ export declare function settledNotice(title: string, requestID: string, reply: Reply): string;
51
+ export interface AnswerPorts {
52
+ readonly storage: RosterStorage;
53
+ /** The permission domains of every loaded location; the request is answered where it is pending. */
54
+ readonly permissions: () => Iterable<Permissions>;
55
+ }
56
+ export interface AnswerInput {
57
+ readonly sessionID: string;
58
+ readonly requestID: string;
59
+ readonly reply: string;
60
+ readonly message?: string;
61
+ }
62
+ /**
63
+ * Answers a permission request of a session started, directly or through others, from the
64
+ * caller, which must be the session at the top: requests go there, to the person, so a session
65
+ * started with courier_spawn cannot approve what its own children ask. `answered` is false when
66
+ * nothing was waiting.
67
+ */
68
+ export declare function answer(ports: AnswerPorts, waiting: Waiting, callerID: string, input: AnswerInput): Promise<{
69
+ sessionID: string;
70
+ requestID: string;
71
+ reply: "once" | "always" | "reject";
72
+ answered: boolean;
73
+ }>;
74
+ /** The requests a session waits on, in whichever location holds them, for courier_status. */
75
+ export declare function pendingOf(permissions: Iterable<Permissions>, sessionID: string): Promise<Pending[]>;
76
+ export {};
package/dist/relay.js ADDED
@@ -0,0 +1,118 @@
1
+ import { describeFailure } from "./courier.js";
2
+ import { answeringTop } from "./roster.js";
3
+ /** The answers to a permission request, as OpenCode's own prompt offers them. */
4
+ export const REPLIES = ["once", "always", "reject"];
5
+ /**
6
+ * Sent with a rejection that has no reason of its own. OpenCode ends the child's turn on a bare
7
+ * rejection, and the child would never report back; with a message, the call fails and it carries on.
8
+ */
9
+ export const REJECTED = "Refused by the session that started you. Do without it, or report back why you cannot.";
10
+ const MAX_RESOURCES = 20;
11
+ const MAX_RESOURCE_LENGTH = 300;
12
+ const clip = (text) => (text.length > MAX_RESOURCE_LENGTH ? `${text.slice(0, MAX_RESOURCE_LENGTH - 3)}...` : text);
13
+ function alwaysChoice(request) {
14
+ const save = request.save ?? [];
15
+ if (!save.length)
16
+ return [];
17
+ const scope = save.length === 1 && save[0] === "*" ? `every ${request.action} request` : `requests matching ${save.join(", ")}`;
18
+ return [`- always: allow it, and from now on ${scope} in this project`];
19
+ }
20
+ /** How a notice names where the asking session came from, to the session at the top of its lineage. */
21
+ export const origin = (startedBy) => startedBy ? `which ${startedBy} started with courier_spawn, a session started from yours,` : "which you started with courier_spawn,";
22
+ /** For a notice that a session's turn ended without it finishing. */
23
+ export const STAYS_QUIET = "and then it does not report back on its own: if it stays quiet, message it with courier_send to have it carry on.";
24
+ /**
25
+ * What the session at the top is told. `startedBy` names the session that started the asking one
26
+ * when that is not the top session itself, but one started from it.
27
+ */
28
+ export function permissionNotice(title, request, startedBy) {
29
+ const resources = request.resources.slice(0, MAX_RESOURCES).map((resource) => `- ${clip(resource)}`);
30
+ if (request.resources.length > MAX_RESOURCES)
31
+ resources.push(`- and ${request.resources.length - MAX_RESOURCES} more`);
32
+ return [
33
+ `This session, "${title}", ${origin(startedBy)} is waiting for permission and does nothing until it is answered.`,
34
+ `It asks for: ${request.action}`,
35
+ ...(resources.length ? ["On:", ...resources] : []),
36
+ ...(request.message ? [`Note: ${request.message}`] : []),
37
+ "",
38
+ "Do not decide this yourself. Ask the person you are working with (with your question tool, if you have one), offering exactly these choices:",
39
+ "- once: allow this request only",
40
+ ...alwaysChoice(request),
41
+ "- reject: refuse it, with a reason if they give one; the session's call fails and it carries on",
42
+ `When they have chosen, call courier_answer with sessionID "${request.sessionID}", requestID "${request.id}", reply set to their choice and, with reject, message set to their reason.`,
43
+ ].join("\n");
44
+ }
45
+ export function settledNotice(title, requestID, reply) {
46
+ return [
47
+ `The permission request ${requestID} of this session, "${title}", has been answered (${reply}) without courier_answer, so it no longer waits on you.`,
48
+ "If you asked someone about it, tell them it is settled; there is nothing to pass on.",
49
+ ...(reply === "reject"
50
+ ? [`A refusal without a reason ends the session's turn, ${STAYS_QUIET}`]
51
+ : []),
52
+ ].join("\n");
53
+ }
54
+ /** A session's pending requests in every location, with the domain holding each; a location that cannot be read gives its error. */
55
+ function listEverywhere(permissions, sessionID) {
56
+ return Promise.all([...permissions].map((domain) => domain.list({ sessionID }).then((requests) => ({ domain, requests: requests.filter((request) => request.sessionID === sessionID) }), (error) => ({ domain, requests: [], error }))));
57
+ }
58
+ /** The location holding a pending request, and the request; undefined when none does, and an error when one could not be read. */
59
+ async function locate(ports, sessionID, requestID) {
60
+ const listed = await listEverywhere(ports.permissions(), sessionID);
61
+ for (const { domain, requests } of listed) {
62
+ const request = requests.find((item) => item.id === requestID);
63
+ if (request)
64
+ return { domain, request };
65
+ }
66
+ const failed = listed.find((item) => "error" in item);
67
+ if (failed && "error" in failed)
68
+ throw failed.error;
69
+ return undefined;
70
+ }
71
+ /**
72
+ * Answers a permission request of a session started, directly or through others, from the
73
+ * caller, which must be the session at the top: requests go there, to the person, so a session
74
+ * started with courier_spawn cannot approve what its own children ask. `answered` is false when
75
+ * nothing was waiting.
76
+ */
77
+ export async function answer(ports, waiting, callerID, input) {
78
+ const reply = input.reply;
79
+ if (!REPLIES.includes(reply))
80
+ throw new Error(`reply must be once, always or reject, not "${input.reply}".`);
81
+ const { sessionID, requestID } = input;
82
+ await answeringTop(ports.storage, sessionID, callerID, "permission requests");
83
+ const found = await locate(ports, sessionID, requestID);
84
+ if (!found) {
85
+ waiting.delete(requestID);
86
+ return { sessionID, requestID, reply, answered: false };
87
+ }
88
+ if (reply === "always" && !found.request.save?.length)
89
+ throw new Error(`always is not offered for ${requestID}: it has nothing to save. Ask again with once or reject.`);
90
+ // Off the waiting list first, so the reply event this answer causes does not come back as a notice.
91
+ waiting.delete(requestID);
92
+ const message = reply === "reject" ? input.message || REJECTED : undefined;
93
+ try {
94
+ await found.domain.reply({ sessionID, requestID, reply, ...(message ? { message } : {}) });
95
+ }
96
+ catch (error) {
97
+ if (await locate(ports, sessionID, requestID).catch(() => found)) {
98
+ waiting.add(requestID);
99
+ throw error;
100
+ }
101
+ throw new Error(`${describeFailure("permission.reply", error).message}, but ${requestID} no longer waits, so it was answered, possibly by this call.`);
102
+ }
103
+ return { sessionID, requestID, reply, answered: true };
104
+ }
105
+ /** The requests a session waits on, in whichever location holds them, for courier_status. */
106
+ export async function pendingOf(permissions, sessionID) {
107
+ const found = new Map();
108
+ for (const { requests } of await listEverywhere(permissions, sessionID))
109
+ for (const request of requests)
110
+ found.set(request.id, {
111
+ type: "permission",
112
+ requestID: request.id,
113
+ action: request.action,
114
+ resources: [...request.resources],
115
+ ...(request.save?.length ? { save: [...request.save] } : {}),
116
+ });
117
+ return [...found.values()];
118
+ }
@@ -0,0 +1,51 @@
1
+ import { type Storage } from "./storage.js";
2
+ /**
3
+ * Entries older than this are dropped when their parent's roster is read, and on plugin setup,
4
+ * except isolated children whose worktree is still there: those stay until courier_cleanup.
5
+ */
6
+ export declare const RETENTION_MS: number;
7
+ /** A session started with courier_spawn, recorded under the session that started it. */
8
+ export interface RosterEntry {
9
+ readonly sessionID: string;
10
+ readonly parentID: string;
11
+ readonly title: string;
12
+ readonly directory: string;
13
+ readonly isolated: boolean;
14
+ readonly createdAt: number;
15
+ /** For an isolated child, the directory its worktree was made from; courier_cleanup removes it through there. */
16
+ readonly source?: string;
17
+ /** For an isolated child, the commit its worktree was made from; its own work is what came after. */
18
+ readonly base?: string;
19
+ }
20
+ export type RosterStorage = Storage;
21
+ export declare function rosterKey(parentID: string, sessionID: string): string;
22
+ export declare function record(storage: RosterStorage, entry: RosterEntry): Promise<void>;
23
+ /** Removes a child from its parent's roster; false when it was not there. */
24
+ export declare function forget(storage: RosterStorage, parentID: string, sessionID: string): Promise<boolean>;
25
+ /** A parent's children, oldest first. */
26
+ export declare function children(storage: RosterStorage, parentID: string): Promise<RosterEntry[]>;
27
+ /** Every parent's children. */
28
+ export declare function allEntries(storage: RosterStorage): Promise<RosterEntry[]>;
29
+ /** A session's roster entries: one for the parent that started it, none if courier_spawn did not. */
30
+ export declare function entriesOf(storage: RosterStorage, sessionID: string): Promise<RosterEntry[]>;
31
+ /**
32
+ * The roster entries from a session up to the top, its own first: the session's, its parent's, and
33
+ * so on, ending with the entry whose parent courier_spawn did not start. Empty when courier_spawn
34
+ * did not start the session.
35
+ */
36
+ export declare function lineage(storage: RosterStorage, sessionID: string): Promise<RosterEntry[]>;
37
+ /** `lineage` over roster entries already read. */
38
+ export declare function lineageIn(entries: ReadonlyArray<RosterEntry>, sessionID: string): RosterEntry[];
39
+ /**
40
+ * The session at the top of a spawned session's lineage, where its permission requests and
41
+ * questions go, since that is where the person is; only it may answer them. Throws when the
42
+ * session was not started with courier_spawn or `callerID` is another session. `what` names
43
+ * what is being answered.
44
+ */
45
+ export declare function answeringTop(storage: RosterStorage, sessionID: string, callerID: string, what: string): Promise<string>;
46
+ /** Whether a directory still exists; tests pass a fake. */
47
+ export type Exists = (directory: string) => boolean;
48
+ /** A parent's children, oldest first, after dropping the expired ones. */
49
+ export declare function current(storage: RosterStorage, parentID: string, now: number, exists?: Exists): Promise<RosterEntry[]>;
50
+ /** Drops expired entries of every parent, so parents that never list their children don't keep them forever. */
51
+ export declare function pruneExpired(storage: RosterStorage, now: number, exists?: Exists): Promise<void>;
package/dist/roster.js ADDED
@@ -0,0 +1,83 @@
1
+ import { existsSync } from "node:fs";
2
+ import { scanAll } from "./storage.js";
3
+ /**
4
+ * Entries older than this are dropped when their parent's roster is read, and on plugin setup,
5
+ * except isolated children whose worktree is still there: those stay until courier_cleanup.
6
+ */
7
+ export const RETENTION_MS = 14 * 24 * 60 * 60_000;
8
+ const PREFIX = "roster/";
9
+ export function rosterKey(parentID, sessionID) {
10
+ return `${PREFIX}${parentID}/${sessionID}`;
11
+ }
12
+ export async function record(storage, entry) {
13
+ await storage.set(rosterKey(entry.parentID, entry.sessionID), { ...entry });
14
+ }
15
+ /** Removes a child from its parent's roster; false when it was not there. */
16
+ export async function forget(storage, parentID, sessionID) {
17
+ const key = rosterKey(parentID, sessionID);
18
+ if ((await storage.get(key)) === undefined)
19
+ return false;
20
+ await storage.remove(key);
21
+ return true;
22
+ }
23
+ /** A parent's children, oldest first. */
24
+ export async function children(storage, parentID) {
25
+ const entries = await scanAll(storage, `${PREFIX}${parentID}/`);
26
+ return entries.sort((a, b) => a.createdAt - b.createdAt);
27
+ }
28
+ /** Every parent's children. */
29
+ export async function allEntries(storage) {
30
+ return scanAll(storage, PREFIX);
31
+ }
32
+ /** A session's roster entries: one for the parent that started it, none if courier_spawn did not. */
33
+ export async function entriesOf(storage, sessionID) {
34
+ return (await scanAll(storage, PREFIX)).filter((entry) => entry.sessionID === sessionID);
35
+ }
36
+ /**
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.
40
+ */
41
+ export async function lineage(storage, sessionID) {
42
+ return lineageIn(await allEntries(storage), sessionID);
43
+ }
44
+ /** `lineage` over roster entries already read. */
45
+ export function lineageIn(entries, sessionID) {
46
+ const bySession = new Map(entries.map((entry) => [entry.sessionID, entry]));
47
+ const chain = [];
48
+ for (let entry = bySession.get(sessionID); entry && !chain.includes(entry); entry = bySession.get(entry.parentID))
49
+ chain.push(entry);
50
+ return chain;
51
+ }
52
+ /**
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.
57
+ */
58
+ export async function answeringTop(storage, sessionID, callerID, what) {
59
+ const chain = await lineage(storage, sessionID);
60
+ if (!chain.length)
61
+ throw new Error(`${sessionID} was not started with courier_spawn, so courier_answer cannot answer for it.`);
62
+ const top = chain.at(-1).parentID;
63
+ if (top !== callerID)
64
+ throw new Error(`${sessionID}'s ${what} go to ${top}, the session at the top of the sessions started from it with courier_spawn; ${callerID} cannot answer them.`);
65
+ return top;
66
+ }
67
+ /**
68
+ * Removes the given entries that were recorded more than `RETENTION_MS` before `now`, and returns
69
+ * the rest. An isolated child is kept while its worktree exists, so it can still be cleaned up.
70
+ */
71
+ async function dropExpired(storage, entries, now, exists) {
72
+ 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))));
74
+ return entries.filter((entry) => !expired.has(entry));
75
+ }
76
+ /** A parent's children, oldest first, after dropping the expired ones. */
77
+ export async function current(storage, parentID, now, exists = existsSync) {
78
+ return dropExpired(storage, await children(storage, parentID), now, exists);
79
+ }
80
+ /** Drops expired entries of every parent, so parents that never list their children don't keep them forever. */
81
+ export async function pruneExpired(storage, now, exists = existsSync) {
82
+ await dropExpired(storage, await scanAll(storage, PREFIX), now, exists);
83
+ }
@@ -0,0 +1,6 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ type Context = Plugin.Context;
3
+ export type Storage = Pick<Context["storage"], "get" | "set" | "remove" | "scan">;
4
+ /** Every value stored under `prefix`, following the scan's pages. */
5
+ export declare function scanAll<T>(storage: Pick<Storage, "scan">, prefix: string): Promise<T[]>;
6
+ export {};
@@ -0,0 +1,12 @@
1
+ /** Every value stored under `prefix`, following the scan's pages. */
2
+ export async function scanAll(storage, prefix) {
3
+ const values = [];
4
+ let after;
5
+ do {
6
+ const page = await storage.scan({ prefix, ...(after ? { after } : {}) });
7
+ for (const entry of page.entries)
8
+ values.push(entry.value);
9
+ after = page.next;
10
+ } while (after);
11
+ return values;
12
+ }
@@ -0,0 +1,69 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ import { type PermissionAsked, type PermissionReplied, type Waiting } from "./relay.js";
3
+ import { type RosterStorage } from "./roster.js";
4
+ type Context = Plugin.Context;
5
+ /** How long to wait before subscribing again after the event stream ended or broke. */
6
+ export declare const RESUBSCRIBE_MS = 5000;
7
+ export interface WatchPorts {
8
+ readonly storage: RosterStorage;
9
+ readonly session: Pick<Context["session"], "synthetic">;
10
+ readonly event: Pick<Context["event"], "subscribe">;
11
+ /** This location's pending permission requests, relayed when the watcher (re)subscribes. */
12
+ readonly permission: Pick<Context["permission"], "list">;
13
+ readonly log: (message: string) => void;
14
+ }
15
+ /**
16
+ * What every plugin instance in the process shares: OpenCode sets the plugin up once per project
17
+ * location, all in one process, and each instance may see the same event, so an event id is
18
+ * claimed synchronously and handled once. `waiting` holds the permission requests a session was
19
+ * told about and has not answered; `answered`, requests answered before anyone was told, so a
20
+ * notice whose roster lookup was overtaken by the answer is not sent.
21
+ */
22
+ export interface WatchState {
23
+ readonly seen: Set<string>;
24
+ readonly waiting: Waiting;
25
+ readonly answered: Set<string>;
26
+ }
27
+ /** The part of OpenCode's `session.execution.failed` event the notice is made from. */
28
+ export interface ExecutionFailed {
29
+ readonly id: string;
30
+ readonly data: {
31
+ readonly sessionID: string;
32
+ readonly error: {
33
+ readonly type: string;
34
+ readonly message: string;
35
+ readonly status?: number | undefined;
36
+ };
37
+ };
38
+ }
39
+ export declare function failureNotice(title: string, error: ExecutionFailed["data"]["error"]): string;
40
+ /** Adds to a bounded set, dropping the oldest entry; false when the value was there already. */
41
+ export declare function claim(set: Set<string>, value: string): boolean;
42
+ /**
43
+ * Tells the parent of a child whose turn failed, since a child that cannot reach its model cannot
44
+ * call courier_send. Returns the parents told.
45
+ */
46
+ export declare function reportFailure(ports: WatchPorts, seen: Set<string>, event: ExecutionFailed): Promise<string[]>;
47
+ /**
48
+ * Tells the session at the top, the parent of a child or the session a chain of children started
49
+ * from, what a waiting child asks and which answers there are, waking it. Returns the sessions told.
50
+ */
51
+ export declare function reportAsked(ports: WatchPorts, state: WatchState, event: PermissionAsked): Promise<string[]>;
52
+ /**
53
+ * Tells the session that was told about a request that it has been answered some other way, in the
54
+ * child's own session or along with another answer, so it does not pass on a stale question.
55
+ * Requests answered through courier_answer are no longer waiting and are skipped.
56
+ */
57
+ export declare function reportReplied(ports: WatchPorts, state: WatchState, event: PermissionReplied): Promise<string[]>;
58
+ /**
59
+ * Relays the requests that spawned sessions in this location already wait on, which the event
60
+ * stream does not repeat: those asked while it was down, before the watcher (re)subscribed.
61
+ */
62
+ export declare function relayPending(ports: WatchPorts, state: WatchState): Promise<void>;
63
+ /**
64
+ * Follows OpenCode's events until `signal` aborts, telling parents when a spawned child's turn
65
+ * fails, when it waits for a permission and when that request is answered without them, and
66
+ * noting the question forms shown, which the question relay waits for.
67
+ */
68
+ export declare function watchChildren(ports: WatchPorts, state: WatchState, signal: AbortSignal, retryMs?: number): Promise<void>;
69
+ export {};
package/dist/watch.js ADDED
@@ -0,0 +1,167 @@
1
+ import { envelope } from "./courier.js";
2
+ import { eventsFollowed, eventsLeft, formShown, formsMayHaveBeenMissed } from "./question.js";
3
+ import { permissionNotice, settledNotice } from "./relay.js";
4
+ import { allEntries, entriesOf, lineage } from "./roster.js";
5
+ /** How long to wait before subscribing again after the event stream ended or broke. */
6
+ export const RESUBSCRIBE_MS = 5_000;
7
+ /** How many handled event ids, and waiting or answered requests, are remembered. */
8
+ const SEEN_MAX = 1_000;
9
+ export function failureNotice(title, error) {
10
+ const status = error.status === undefined ? "" : `, status ${error.status}`;
11
+ return [
12
+ `This session, "${title}", which you started with courier_spawn, failed: ${error.message} (${error.type}${status}).`,
13
+ "Its turn ended without finishing, so it will not report back on its own.",
14
+ "Message it with courier_send to have it try again, start a replacement, or carry on without it.",
15
+ ].join("\n");
16
+ }
17
+ /** Adds to a bounded set, dropping the oldest entry; false when the value was there already. */
18
+ export function claim(set, value) {
19
+ if (set.has(value))
20
+ return false;
21
+ set.add(value);
22
+ if (set.size > SEEN_MAX)
23
+ set.delete(set.values().next().value);
24
+ return true;
25
+ }
26
+ /**
27
+ * Tells the parent of a child whose turn failed, since a child that cannot reach its model cannot
28
+ * call courier_send. Returns the parents told.
29
+ */
30
+ export async function reportFailure(ports, seen, event) {
31
+ if (!claim(seen, event.id))
32
+ return [];
33
+ const { sessionID, error } = event.data;
34
+ const entries = await entriesOf(ports.storage, sessionID);
35
+ for (const entry of entries)
36
+ await ports.session.synthetic({
37
+ sessionID: entry.parentID,
38
+ text: envelope(sessionID, failureNotice(entry.title, error), { failed: error.type }),
39
+ description: `Session ${sessionID} failed`,
40
+ metadata: { source: "courier", from: sessionID, failed: true },
41
+ delivery: "steer",
42
+ });
43
+ return entries.map((entry) => entry.parentID);
44
+ }
45
+ /** The session at the top of a lineage: where its permission requests go, since that is where the person is. */
46
+ const topOf = (chain) => chain.at(-1).parentID;
47
+ /**
48
+ * Tells the session at the top, the parent of a child or the session a chain of children started
49
+ * from, what a waiting child asks and which answers there are, waking it. Returns the sessions told.
50
+ */
51
+ export async function reportAsked(ports, state, event) {
52
+ if (!claim(state.seen, event.id))
53
+ return [];
54
+ const request = event.data;
55
+ const chain = await lineage(ports.storage, request.sessionID);
56
+ // Claimed only now, after the lookup: a request answered meanwhile is in `answered`, and one
57
+ // already relayed, from the event or from the pending list, is in `waiting`.
58
+ if (!chain.length || state.answered.has(request.id) || !claim(state.waiting, request.id))
59
+ return [];
60
+ const startedBy = chain.length > 1 ? chain[0].parentID : undefined;
61
+ await ports.session.synthetic({
62
+ sessionID: topOf(chain),
63
+ text: envelope(request.sessionID, permissionNotice(chain[0].title, request, startedBy), {
64
+ asks: "permission",
65
+ request: request.id,
66
+ }),
67
+ description: `Session ${request.sessionID} asks for permission`,
68
+ metadata: { source: "courier", from: request.sessionID, asks: "permission", requestID: request.id },
69
+ delivery: "steer",
70
+ });
71
+ return [topOf(chain)];
72
+ }
73
+ /**
74
+ * Tells the session that was told about a request that it has been answered some other way, in the
75
+ * child's own session or along with another answer, so it does not pass on a stale question.
76
+ * Requests answered through courier_answer are no longer waiting and are skipped.
77
+ */
78
+ export async function reportReplied(ports, state, event) {
79
+ if (!claim(state.seen, event.id))
80
+ return [];
81
+ const { sessionID, requestID, reply } = event.data;
82
+ if (!state.waiting.delete(requestID)) {
83
+ claim(state.answered, requestID);
84
+ return [];
85
+ }
86
+ const chain = await lineage(ports.storage, sessionID);
87
+ if (!chain.length)
88
+ return [];
89
+ await ports.session.synthetic({
90
+ sessionID: topOf(chain),
91
+ text: envelope(sessionID, settledNotice(chain[0].title, requestID, reply), { answered: reply, request: requestID }),
92
+ description: `Session ${sessionID} no longer asks for permission`,
93
+ metadata: { source: "courier", from: sessionID, answered: reply, requestID },
94
+ delivery: "steer",
95
+ });
96
+ return [topOf(chain)];
97
+ }
98
+ /**
99
+ * Relays the requests that spawned sessions in this location already wait on, which the event
100
+ * stream does not repeat: those asked while it was down, before the watcher (re)subscribed.
101
+ */
102
+ export async function relayPending(ports, state) {
103
+ const sessions = new Set((await allEntries(ports.storage)).map((entry) => entry.sessionID));
104
+ for (const sessionID of sessions)
105
+ for (const request of await ports.permission.list({ sessionID }).catch(() => []))
106
+ await reportAsked(ports, state, { id: `pending:${request.id}`, data: request });
107
+ }
108
+ const pause = (ms, signal) => new Promise((resolve) => {
109
+ const timer = setTimeout(done, ms);
110
+ function done() {
111
+ clearTimeout(timer);
112
+ signal.removeEventListener("abort", done);
113
+ resolve();
114
+ }
115
+ signal.addEventListener("abort", done);
116
+ });
117
+ /** Handles one event of OpenCode's stream; those that cannot concern a spawned child are ignored. */
118
+ async function handle(ports, state, event) {
119
+ if (event.type === "session.execution.failed")
120
+ return reportFailure(ports, state.seen, event);
121
+ if (event.type === "permission.asked")
122
+ return reportAsked(ports, state, event);
123
+ if (event.type === "permission.replied")
124
+ return reportReplied(ports, state, event);
125
+ // Not claimed: every instance may resolve the same waiting call, which is harmless.
126
+ if (event.type === "form.created")
127
+ formShown(event);
128
+ return [];
129
+ }
130
+ /**
131
+ * Follows OpenCode's events until `signal` aborts, telling parents when a spawned child's turn
132
+ * fails, when it waits for a permission and when that request is answered without them, and
133
+ * noting the question forms shown, which the question relay waits for.
134
+ */
135
+ export async function watchChildren(ports, state, signal, retryMs = RESUBSCRIBE_MS) {
136
+ while (!signal.aborted) {
137
+ let following = false;
138
+ try {
139
+ const events = ports.event.subscribe({ signal });
140
+ // Question forms shown while no instance followed the events were not seen: released now,
141
+ // and again on the first event, by when the stream is surely connected.
142
+ let missed = eventsFollowed();
143
+ following = true;
144
+ // Alongside the new subscription; a request both relays see is relayed once.
145
+ void relayPending(ports, state).catch((error) => ports.log(`courier watch: could not relay pending requests: ${String(error)}`));
146
+ for await (const event of events) {
147
+ if (missed)
148
+ formsMayHaveBeenMissed();
149
+ missed = false;
150
+ await handle(ports, state, event).catch((error) => {
151
+ const sessionID = event.data?.sessionID;
152
+ ports.log(`courier watch: could not handle ${event.type} of ${sessionID}: ${String(error)}`);
153
+ });
154
+ }
155
+ }
156
+ catch (error) {
157
+ if (!signal.aborted)
158
+ ports.log(`courier watch: event stream broke: ${String(error)}`);
159
+ }
160
+ finally {
161
+ if (following)
162
+ eventsLeft();
163
+ }
164
+ if (!signal.aborted)
165
+ await pause(retryMs, signal);
166
+ }
167
+ }