opencode-courier 0.2.2 → 0.2.3

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 (46) hide show
  1. package/README.md +130 -95
  2. package/dist/bounded.d.ts +10 -0
  3. package/dist/bounded.js +21 -0
  4. package/dist/cleanup.d.ts +13 -18
  5. package/dist/cleanup.js +38 -19
  6. package/dist/courier.d.ts +1 -5
  7. package/dist/courier.js +1 -20
  8. package/dist/hub.d.ts +325 -0
  9. package/dist/hub.js +340 -0
  10. package/dist/index.d.ts +4 -8
  11. package/dist/index.js +70 -308
  12. package/dist/json.d.ts +4 -0
  13. package/dist/json.js +4 -0
  14. package/dist/later.d.ts +1 -1
  15. package/dist/later.js +2 -2
  16. package/dist/notices.d.ts +246 -0
  17. package/dist/notices.js +399 -0
  18. package/dist/question/answer.d.ts +35 -0
  19. package/dist/question/answer.js +217 -0
  20. package/dist/question/index.d.ts +6 -0
  21. package/dist/question/index.js +7 -0
  22. package/dist/question/lifecycle.d.ts +52 -0
  23. package/dist/question/lifecycle.js +100 -0
  24. package/dist/question/pure.d.ts +49 -0
  25. package/dist/question/pure.js +75 -0
  26. package/dist/question/relay.d.ts +5 -0
  27. package/dist/question/relay.js +283 -0
  28. package/dist/question/runtime.d.ts +21 -0
  29. package/dist/question/runtime.js +75 -0
  30. package/dist/question/shared.d.ts +24 -0
  31. package/dist/question/shared.js +24 -0
  32. package/dist/relay.d.ts +31 -33
  33. package/dist/relay.js +5 -53
  34. package/dist/roster.d.ts +29 -1
  35. package/dist/roster.js +101 -9
  36. package/dist/storage.d.ts +8 -1
  37. package/dist/storage.js +8 -5
  38. package/dist/tools.d.ts +74 -0
  39. package/dist/tools.js +228 -0
  40. package/dist/watch.d.ts +28 -37
  41. package/dist/watch.js +148 -38
  42. package/dist/webhook.d.ts +1 -13
  43. package/dist/webhook.js +36 -127
  44. package/package.json +16 -5
  45. package/dist/question.d.ts +0 -158
  46. package/dist/question.js +0 -768
package/dist/roster.js CHANGED
@@ -1,23 +1,53 @@
1
1
  import { existsSync } from "node:fs";
2
- import { scanAll } from "./storage.js";
2
+ import { scanAll, scanEntries } from "./storage.js";
3
3
  /**
4
4
  * Entries older than this are dropped when their parent's roster is read, and on plugin setup,
5
5
  * except isolated children whose worktree is still there: those stay until courier_cleanup.
6
6
  */
7
7
  export const RETENTION_MS = 14 * 24 * 60 * 60_000;
8
8
  const PREFIX = "roster/";
9
+ /**
10
+ * The reverse index: under `roster-by-child/<sessionID>`, a child's `ReverseEntry`, written with its
11
+ * roster entry. It is only an index: an entry without one, written by an older copy of the plugin,
12
+ * is found by scanning `roster/`, and every entry it leads to is read from `roster/` to check that
13
+ * it is still there.
14
+ */
15
+ const BY_CHILD = "roster-by-child/";
9
16
  export function rosterKey(parentID, sessionID) {
10
17
  return `${PREFIX}${parentID}/${sessionID}`;
11
18
  }
19
+ export function reverseKey(sessionID) {
20
+ return `${BY_CHILD}${sessionID}`;
21
+ }
22
+ /**
23
+ * Records a child under its parent, then indexes it. The entry is what counts: a failed index
24
+ * write leaves it to be found by a scan, and to be indexed when the plugin is next loaded.
25
+ */
12
26
  export async function record(storage, entry) {
13
27
  await storage.set(rosterKey(entry.parentID, entry.sessionID), { ...entry });
28
+ await index(storage, entry).catch(() => undefined);
29
+ }
30
+ async function index(storage, entry) {
31
+ const above = await storage.get(reverseKey(entry.parentID));
32
+ const ancestors = isReverse(above) ? above.ancestors : (await lineage(storage, entry.parentID)).map((parent) => parent.parentID);
33
+ await storage.set(reverseKey(entry.sessionID), { ancestors: [entry.parentID, ...ancestors] });
34
+ }
35
+ function isReverse(value) {
36
+ const ancestors = value?.ancestors;
37
+ return Array.isArray(ancestors) && ancestors.length > 0 && ancestors.every((id) => typeof id === "string");
38
+ }
39
+ /**
40
+ * Removes a child's roster entry and its reverse key. Only the entry's removal can fail it: a
41
+ * reverse key left behind leads nowhere, and the next load drops it.
42
+ */
43
+ export async function remove(storage, parentID, sessionID) {
44
+ await Promise.all([storage.remove(rosterKey(parentID, sessionID)), storage.remove(reverseKey(sessionID)).catch(() => undefined)]);
14
45
  }
15
46
  /** Removes a child from its parent's roster; false when it was not there. */
16
47
  export async function forget(storage, parentID, sessionID) {
17
- const key = rosterKey(parentID, sessionID);
18
- if ((await storage.get(key)) === undefined)
48
+ if ((await storage.get(rosterKey(parentID, sessionID))) === undefined)
19
49
  return false;
20
- await storage.remove(key);
50
+ await remove(storage, parentID, sessionID);
21
51
  return true;
22
52
  }
23
53
  /** A parent's children, oldest first. */
@@ -31,7 +61,11 @@ export async function allEntries(storage) {
31
61
  }
32
62
  /** A session's roster entries: one for the parent that started it, none if courier_spawn did not. */
33
63
  export async function entriesOf(storage, sessionID) {
34
- return (await scanAll(storage, PREFIX)).filter((entry) => entry.sessionID === sessionID);
64
+ const indexed = await storage.get(reverseKey(sessionID));
65
+ if (!isReverse(indexed))
66
+ return (await scanAll(storage, PREFIX)).filter((entry) => entry.sessionID === sessionID);
67
+ const entry = await storage.get(rosterKey(indexed.ancestors[0], sessionID));
68
+ return entry === undefined ? [] : [entry];
35
69
  }
36
70
  /**
37
71
  * The roster entries from a session up to the top, its own first: the session's, its parent's, and
@@ -39,7 +73,31 @@ export async function entriesOf(storage, sessionID) {
39
73
  * did not start the session.
40
74
  */
41
75
  export async function lineage(storage, sessionID) {
42
- return lineageIn(await allEntries(storage), sessionID);
76
+ const first = await storage.get(reverseKey(sessionID));
77
+ if (!isReverse(first))
78
+ return lineageIn(await allEntries(storage), sessionID);
79
+ let indexed = first;
80
+ const chain = [];
81
+ let below = sessionID;
82
+ // One read per level, all at once, and one more for the top's own reverse key, in case it was
83
+ // recorded after the key that names it; the chain ends where an entry is gone, as a scan's would.
84
+ for (;;) {
85
+ const { ancestors } = indexed;
86
+ const top = ancestors.at(-1);
87
+ const [entries, above] = await Promise.all([
88
+ Promise.all(ancestors.map((parentID, level) => storage.get(rosterKey(parentID, level ? ancestors[level - 1] : below)))),
89
+ storage.get(reverseKey(top)),
90
+ ]);
91
+ for (const entry of entries) {
92
+ if (!entry || chain.some((known) => known.sessionID === entry.sessionID))
93
+ return chain;
94
+ chain.push(entry);
95
+ }
96
+ if (!isReverse(above))
97
+ return chain;
98
+ below = top;
99
+ indexed = above;
100
+ }
43
101
  }
44
102
  /** `lineage` over roster entries already read. */
45
103
  export function lineageIn(entries, sessionID) {
@@ -70,14 +128,48 @@ export async function answeringTop(storage, sessionID, callerID, what) {
70
128
  */
71
129
  async function dropExpired(storage, entries, now, exists) {
72
130
  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))));
131
+ await Promise.all([...expired].map((entry) => remove(storage, entry.parentID, entry.sessionID)));
74
132
  return entries.filter((entry) => !expired.has(entry));
75
133
  }
76
134
  /** A parent's children, oldest first, after dropping the expired ones. */
77
135
  export async function current(storage, parentID, now, exists = existsSync) {
78
136
  return dropExpired(storage, await children(storage, parentID), now, exists);
79
137
  }
80
- /** Drops expired entries of every parent, so parents that never list their children don't keep them forever. */
138
+ /**
139
+ * On loading: drops expired entries of every parent, so parents that never list their children
140
+ * don't keep them forever, then brings the reverse index in line with what is left (`backfill`).
141
+ */
81
142
  export async function pruneExpired(storage, now, exists = existsSync) {
82
- await dropExpired(storage, await scanAll(storage, PREFIX), now, exists);
143
+ await backfill(storage, await dropExpired(storage, await scanAll(storage, PREFIX), now, exists));
144
+ }
145
+ /** Whether a reverse key's value leads to this entry. */
146
+ function indexes(value, entry) {
147
+ return isReverse(value) && value.ancestors[0] === entry.parentID;
148
+ }
149
+ /**
150
+ * Writes the reverse keys missing for the given entries, or naming another parent, all of the roster, such as those an older
151
+ * copy of the plugin recorded, and drops the reverse keys whose entry is gone, such as those of an
152
+ * entry an older copy removed. Keys that lead to their entry are left as they are, so running it
153
+ * again changes nothing.
154
+ */
155
+ export async function backfill(storage, entries) {
156
+ const indexed = new Map((await scanEntries(storage, BY_CHILD)).map(({ key, value }) => [key, value]));
157
+ const sessions = new Set(entries.map((entry) => entry.sessionID));
158
+ await Promise.all([
159
+ ...entries
160
+ .filter((entry) => !indexes(indexed.get(entry.sessionID), entry))
161
+ .map((entry) => storage.set(reverseKey(entry.sessionID), { ancestors: lineageIn(entries, entry.sessionID).map((above) => above.parentID) })),
162
+ ...[...indexed]
163
+ .filter(([sessionID]) => !sessions.has(sessionID))
164
+ .map(([sessionID, value]) => dropDangling(storage, sessionID, value)),
165
+ ]);
166
+ }
167
+ /**
168
+ * Drops a reverse key whose entry was not in the roster read, unless that entry is there now:
169
+ * another instance may have recorded the child since, and it writes the entry before the key.
170
+ */
171
+ async function dropDangling(storage, sessionID, value) {
172
+ if (isReverse(value) && (await storage.get(rosterKey(value.ancestors[0], sessionID))) !== undefined)
173
+ return;
174
+ await storage.remove(reverseKey(sessionID));
83
175
  }
package/dist/storage.d.ts CHANGED
@@ -3,9 +3,16 @@ type Context = Plugin.Context;
3
3
  export type Storage = Pick<Context["storage"], "get" | "set" | "remove" | "scan">;
4
4
  /** Every value stored under `prefix`, following the scan's pages. */
5
5
  export declare function scanAll<T>(storage: Pick<Storage, "scan">, prefix: string): Promise<T[]>;
6
+ /** Every key under `prefix`, without the prefix, with its value, following the scan's pages. */
7
+ export declare function scanEntries<T>(storage: Pick<Storage, "scan">, prefix: string): Promise<{
8
+ key: string;
9
+ value: T;
10
+ }[]>;
11
+ /** Where process-wide values are kept: `globalThis`, or a stand-in for it in tests. */
12
+ export type Registry = Record<symbol, unknown>;
6
13
  /**
7
14
  * The one value the whole process keeps under `Symbol.for(key)`, made by `create` the first time,
8
15
  * so every instance of the plugin, and every copy of it loaded later, gets the same one.
9
16
  */
10
- export declare function processWide<T>(key: string, create: () => T): T;
17
+ export declare function processWide<T>(key: string, create: () => T, registry?: Registry): T;
11
18
  export {};
package/dist/storage.js CHANGED
@@ -1,21 +1,24 @@
1
1
  /** Every value stored under `prefix`, following the scan's pages. */
2
2
  export async function scanAll(storage, prefix) {
3
- const values = [];
3
+ return (await scanEntries(storage, prefix)).map((entry) => entry.value);
4
+ }
5
+ /** Every key under `prefix`, without the prefix, with its value, following the scan's pages. */
6
+ export async function scanEntries(storage, prefix) {
7
+ const entries = [];
4
8
  let after;
5
9
  do {
6
10
  const page = await storage.scan({ prefix, ...(after ? { after } : {}) });
7
11
  for (const entry of page.entries)
8
- values.push(entry.value);
12
+ entries.push({ key: entry.key.slice(prefix.length), value: entry.value });
9
13
  after = page.next;
10
14
  } while (after);
11
- return values;
15
+ return entries;
12
16
  }
13
17
  /**
14
18
  * The one value the whole process keeps under `Symbol.for(key)`, made by `create` the first time,
15
19
  * so every instance of the plugin, and every copy of it loaded later, gets the same one.
16
20
  */
17
- export function processWide(key, create) {
18
- const registry = globalThis;
21
+ export function processWide(key, create, registry = globalThis) {
19
22
  const symbol = Symbol.for(key);
20
23
  registry[symbol] ??= create();
21
24
  return registry[symbol];
@@ -0,0 +1,74 @@
1
+ import type { Plugin } from "@opencode/plugin";
2
+ import { Schema } from "effect";
3
+ import { type CleanupPorts } from "./cleanup.js";
4
+ import { type CourierPorts } from "./courier.js";
5
+ import { type LaterPorts } from "./later.js";
6
+ import { type QuestionPorts } from "./question/index.js";
7
+ import { type AnswerPorts, type Waiting } from "./relay.js";
8
+ import { type WebhookPorts } from "./webhook.js";
9
+ /**
10
+ * The courier tools: their input schemas, descriptions, and what each returns. The descriptions are
11
+ * model-facing like the texts in `notices.ts`, and snapshotted with them in `test/notices.test.ts`.
12
+ */
13
+ export declare const SpawnInput: Schema.Struct<{
14
+ readonly task: Schema.String;
15
+ readonly title: Schema.optional<Schema.String>;
16
+ readonly agent: Schema.optional<Schema.String>;
17
+ readonly isolate: Schema.optional<Schema.Boolean>;
18
+ }>;
19
+ export declare const SendInput: Schema.Struct<{
20
+ readonly sessionID: Schema.String;
21
+ readonly message: Schema.String;
22
+ readonly queue: Schema.optional<Schema.Boolean>;
23
+ }>;
24
+ export declare const StatusInput: Schema.Struct<{
25
+ readonly sessionID: Schema.String;
26
+ }>;
27
+ export declare const ChildrenInput: Schema.Struct<{
28
+ readonly sessionID: Schema.optional<Schema.String>;
29
+ }>;
30
+ export declare const CleanupInput: Schema.Struct<{
31
+ readonly sessionID: Schema.String;
32
+ readonly force: Schema.optional<Schema.Boolean>;
33
+ }>;
34
+ export declare const LaterInput: Schema.Struct<{
35
+ readonly message: Schema.String;
36
+ readonly delayMinutes: Schema.optional<Schema.Union<readonly [Schema.Number, Schema.String]>>;
37
+ readonly at: Schema.optional<Schema.String>;
38
+ readonly sessionID: Schema.optional<Schema.String>;
39
+ }>;
40
+ export declare const AnswerInput: Schema.Struct<{
41
+ readonly sessionID: Schema.String;
42
+ readonly requestID: Schema.String;
43
+ readonly reply: Schema.optional<Schema.Literals<readonly ["once", "always", "reject"]>>;
44
+ readonly message: Schema.optional<Schema.String>;
45
+ readonly answers: Schema.optional<Schema.$Array<Schema.Union<readonly [Schema.String, Schema.$Array<Schema.String>]>>>;
46
+ }>;
47
+ export declare const CancelInput: Schema.Struct<{
48
+ readonly id: Schema.String;
49
+ }>;
50
+ export declare const SubscribeInput: Schema.Struct<{
51
+ readonly topic: Schema.String;
52
+ readonly sessionID: Schema.optional<Schema.String>;
53
+ }>;
54
+ export declare const UnsubscribeInput: Schema.Struct<{
55
+ readonly topic: Schema.optional<Schema.String>;
56
+ readonly sessionID: Schema.optional<Schema.String>;
57
+ }>;
58
+ /** The editor OpenCode hands a tool transform. */
59
+ export type ToolEditor = Parameters<Parameters<Plugin.Context["tool"]["transform"]>[0]>[0];
60
+ /** What the tools run on: one plugin instance's ports. */
61
+ export interface ToolPorts {
62
+ readonly courier: CourierPorts;
63
+ readonly cleanup: CleanupPorts;
64
+ readonly later: LaterPorts;
65
+ readonly answer: AnswerPorts;
66
+ readonly questions: QuestionPorts;
67
+ readonly hooks: WebhookPorts;
68
+ /** The permission requests sessions were told about and have not answered. */
69
+ readonly waiting: Waiting;
70
+ /** Whether a webhook receiver runs in this server. */
71
+ readonly receiving: () => Promise<boolean>;
72
+ }
73
+ /** Adds the courier tools, each a direct tool rather than a code-mode one. */
74
+ export declare function addTools(tools: ToolEditor, ports: ToolPorts): void;
package/dist/tools.js ADDED
@@ -0,0 +1,228 @@
1
+ import { Schema } from "effect";
2
+ import { cleanup } from "./cleanup.js";
3
+ import { describeFailure, listChildren, send, spawn, status } from "./courier.js";
4
+ import { cancel, schedule } from "./later.js";
5
+ import { answerText, cancelText, childrenText, cleanupText, laterText, REPLIES, sendText, spawnText, statusText, subscribeText, unsubscribeText, } from "./notices.js";
6
+ import { answerQuestion, isQuestion } from "./question/index.js";
7
+ import { answer } from "./relay.js";
8
+ import { subscribe, unsubscribe } from "./webhook.js";
9
+ /**
10
+ * The courier tools: their input schemas, descriptions, and what each returns. The descriptions are
11
+ * model-facing like the texts in `notices.ts`, and snapshotted with them in `test/notices.test.ts`.
12
+ */
13
+ export const SpawnInput = Schema.Struct({
14
+ task: Schema.String.annotate({ description: "What the new session should do. It is told who started it and how to report back." }),
15
+ title: Schema.optional(Schema.String.annotate({ description: "Session title; defaults to the task's first line." })),
16
+ agent: Schema.optional(Schema.String.annotate({ description: "Agent to run the session with; defaults to the default agent." })),
17
+ isolate: Schema.optional(Schema.Boolean.annotate({ description: "Run the session in its own git worktree so parallel sessions don't share files." })),
18
+ });
19
+ export const SendInput = Schema.Struct({
20
+ sessionID: Schema.String.annotate({ description: "The session to deliver to." }),
21
+ message: Schema.String.annotate({ description: "The message text." }),
22
+ queue: Schema.optional(Schema.Boolean.annotate({ description: "Wait until the target's current turn ends instead of steering it now." })),
23
+ });
24
+ export const StatusInput = Schema.Struct({
25
+ sessionID: Schema.String.annotate({ description: "The session to look at." }),
26
+ });
27
+ export const ChildrenInput = Schema.Struct({
28
+ sessionID: Schema.optional(Schema.String.annotate({ description: "The session whose children to list; defaults to this one." })),
29
+ });
30
+ export const CleanupInput = Schema.Struct({
31
+ sessionID: Schema.String.annotate({ description: "The isolated child whose worktree to remove." }),
32
+ force: Schema.optional(Schema.Boolean.annotate({
33
+ description: "Remove it even with uncommitted changes or commits on no branch; that work is lost.",
34
+ })),
35
+ });
36
+ export const LaterInput = Schema.Struct({
37
+ message: Schema.String.annotate({ description: "The message to deliver." }),
38
+ delayMinutes: Schema.optional(
39
+ // Some models send the number as a string ("3", "3.0") whatever the schema says, and resend it
40
+ // unchanged after the error, so a string is accepted and schedule reads it as a number.
41
+ Schema.Union([Schema.Number, Schema.String]).annotate({
42
+ description: "Deliver this many minutes from now. Give this or at.",
43
+ })),
44
+ at: Schema.optional(Schema.String.annotate({ description: "Deliver at this ISO 8601 time. Give this or delayMinutes." })),
45
+ sessionID: Schema.optional(Schema.String.annotate({ description: "The session to deliver to; defaults to this one." })),
46
+ });
47
+ export const AnswerInput = Schema.Struct({
48
+ sessionID: Schema.String.annotate({ description: "The session you started that is waiting, as its notice names it." }),
49
+ requestID: Schema.String.annotate({ description: "The request id from the notice." }),
50
+ reply: Schema.optional(Schema.Literals(REPLIES).annotate({
51
+ description: "For a permission request: the choice the person made, once, always (only when the notice offers it) or reject.",
52
+ })),
53
+ message: Schema.optional(Schema.String.annotate({ description: "For a permission request, with reject: the person's reason, passed on with the refusal." })),
54
+ answers: Schema.optional(Schema.Array(Schema.Union([Schema.String, Schema.Array(Schema.String)])).annotate({
55
+ description: "For a question: one entry per question, in order, each the label the person chose or the text they gave, " +
56
+ "or a list of labels where the question allows several.",
57
+ })),
58
+ });
59
+ export const CancelInput = Schema.Struct({
60
+ id: Schema.String.annotate({ description: "The id courier_later returned." }),
61
+ });
62
+ export const SubscribeInput = Schema.Struct({
63
+ topic: Schema.String.annotate({
64
+ description: "owner/repo for every event of a GitHub repository, owner/repo#<number> for one pull request or issue " +
65
+ "(reviews, comments, completed CI runs), or a plain name for deliveries posted to /hook/<name>.",
66
+ }),
67
+ sessionID: Schema.optional(Schema.String.annotate({ description: "The session to subscribe; defaults to this one." })),
68
+ });
69
+ export const UnsubscribeInput = Schema.Struct({
70
+ topic: Schema.optional(Schema.String.annotate({ description: "The topic to drop; all of this session's when omitted." })),
71
+ sessionID: Schema.optional(Schema.String.annotate({ description: "The session to unsubscribe; defaults to this one." })),
72
+ });
73
+ const rethrow = (tool) => (error) => {
74
+ throw describeFailure(tool, error);
75
+ };
76
+ // One tool answers both: a question's id is the plugin's own, a permission request's OpenCode's.
77
+ async function answerRequest(ports, callerID, input) {
78
+ const { sessionID, requestID } = input;
79
+ if (isQuestion(requestID)) {
80
+ if (input.reply !== undefined || input.message !== undefined)
81
+ throw new Error(`${requestID} is a question: pass the person's answers in answers, not reply or message.`);
82
+ if (input.answers === undefined)
83
+ throw new Error(`${requestID} is a question: answers is required, one entry per question.`);
84
+ return answerQuestion(ports.questions, callerID, { sessionID, requestID, answers: input.answers });
85
+ }
86
+ if (input.answers !== undefined)
87
+ throw new Error(`${requestID} is a permission request: pass the person's choice in reply (once, always or reject), not answers.`);
88
+ if (input.reply === undefined)
89
+ throw new Error(`${requestID} is a permission request: reply is required, once, always or reject.`);
90
+ return answer(ports.answer, ports.waiting, callerID, {
91
+ sessionID,
92
+ requestID,
93
+ reply: input.reply,
94
+ ...(input.message !== undefined ? { message: input.message } : {}),
95
+ });
96
+ }
97
+ /** Adds the courier tools, each a direct tool rather than a code-mode one. */
98
+ export function addTools(tools, ports) {
99
+ tools.add({
100
+ name: "courier_spawn",
101
+ options: { codemode: false },
102
+ description: "Start a new OpenCode session on a task and return immediately. It runs on your model. The session reports " +
103
+ "back with courier_send, which wakes this session, and you are told if its turn fails instead, it waits for a " +
104
+ "permission or it asks a question. DO NOT poll it or call courier_status in a loop: once you have started the sessions " +
105
+ "you need, end your turn by replying without calling more tools; each report starts a new turn in which you " +
106
+ "carry on. For long tasks, also courier_later a check-in for yourself in case it never reports, and " +
107
+ "courier_cancel it when it does.",
108
+ input: SpawnInput,
109
+ execute: async (input, context) => {
110
+ const child = await spawn(ports.courier, context.sessionID, input).catch(rethrow("courier_spawn"));
111
+ return { content: spawnText(child), metadata: child };
112
+ },
113
+ });
114
+ tools.add({
115
+ name: "courier_send",
116
+ options: { codemode: false },
117
+ description: "Deliver a message to another OpenCode session. If that session is idle, OpenCode starts a new turn for it. " +
118
+ "Use it to report back to the session that started you, or to steer a session you started.",
119
+ input: SendInput,
120
+ execute: async (input, context) => {
121
+ const delivered = await send(ports.courier, context.sessionID, input).catch(rethrow("courier_send"));
122
+ return { content: sendText(input.sessionID), metadata: delivered };
123
+ },
124
+ });
125
+ tools.add({
126
+ name: "courier_status",
127
+ options: { codemode: false },
128
+ description: "Look once at a session's state and its last reply, e.g. on a scheduled check-in. Not for waiting: " +
129
+ "sessions you started report back on their own.",
130
+ input: StatusInput,
131
+ execute: async (input) => {
132
+ const result = await status(ports.courier, input).catch(rethrow("courier_status"));
133
+ return { content: statusText(result), metadata: result };
134
+ },
135
+ });
136
+ tools.add({
137
+ name: "courier_children",
138
+ options: { codemode: false },
139
+ description: "List the sessions this one started with courier_spawn, with each one's state and last reply, e.g. after a " +
140
+ "compaction or restart. Like courier_status, for a one-off look, not for waiting.",
141
+ input: ChildrenInput,
142
+ execute: async (input, context) => {
143
+ const listed = await listChildren(ports.courier, input.sessionID || context.sessionID).catch(rethrow("courier_children"));
144
+ return { content: childrenText(listed), metadata: { children: listed } };
145
+ },
146
+ });
147
+ tools.add({
148
+ name: "courier_cleanup",
149
+ options: { codemode: false },
150
+ description: "Remove the git worktree of a session you started with isolate: true, once you have what you need from it, " +
151
+ "and drop it from courier_children. A worktree with uncommitted changes or commits on no branch is kept and " +
152
+ "the result lists them; commit or branch what you want, or pass force: true to discard it.",
153
+ input: CleanupInput,
154
+ execute: async (input, context) => {
155
+ const result = await cleanup(ports.cleanup, context.sessionID, input).catch(rethrow("courier_cleanup"));
156
+ return { content: cleanupText(result), metadata: result };
157
+ },
158
+ });
159
+ tools.add({
160
+ name: "courier_answer",
161
+ options: { codemode: false },
162
+ description: "Pass on the answer to a permission request or a question that a session you started with courier_spawn (or " +
163
+ "one started from it) waits on, after a notice from it named the request. The answer is the person's, not yours: " +
164
+ "first ask the person you are working with, offering the choices the notice lists, then call this with what " +
165
+ "they chose: reply for a permission request, answers for a question. Never choose for them. The session " +
166
+ "carries on once it has the answer.",
167
+ input: AnswerInput,
168
+ execute: async (input, context) => {
169
+ const result = await answerRequest(ports, context.sessionID, input).catch(rethrow("courier_answer"));
170
+ return { content: answerText(result), metadata: result };
171
+ },
172
+ });
173
+ tools.add({
174
+ name: "courier_later",
175
+ options: { codemode: false },
176
+ description: "Schedule a message for a session (this one by default), delivered when due and waking it if idle. " +
177
+ "Use it as a safety net when you start sessions: schedule one check-in, end your turn by replying without " +
178
+ "calling more tools, and cancel it with courier_cancel if the child reports first. Survives server restarts; " +
179
+ "may arrive up to ~15 seconds late.",
180
+ input: LaterInput,
181
+ execute: async (input, context) => {
182
+ const entry = await schedule(ports.later, context.sessionID, input).catch(rethrow("courier_later"));
183
+ const fireAt = new Date(entry.fireAt).toISOString();
184
+ return {
185
+ content: laterText(entry, fireAt, entry.sessionID === context.sessionID),
186
+ metadata: { id: entry.id, fireAt, sessionID: entry.sessionID },
187
+ };
188
+ },
189
+ });
190
+ tools.add({
191
+ name: "courier_cancel",
192
+ options: { codemode: false },
193
+ description: "Cancel a message scheduled with courier_later, e.g. because the child it was waiting for reported.",
194
+ input: CancelInput,
195
+ execute: async (input) => {
196
+ const cancelled = await cancel(ports.later, input.id).catch(rethrow("courier_cancel"));
197
+ return { content: cancelText(input.id, cancelled), metadata: { id: input.id, cancelled } };
198
+ },
199
+ });
200
+ tools.add({
201
+ name: "courier_subscribe",
202
+ options: { codemode: false },
203
+ description: "Wake a session (this one by default) when a webhook arrives for a topic: a GitHub repository, one of its " +
204
+ "pull requests or issues (reviews, comments, completed CI runs), or a named generic hook. Each matching " +
205
+ "delivery arrives as a message, queued behind any running turn. End your turn and wait; do not poll.",
206
+ input: SubscribeInput,
207
+ execute: async (input, context) => {
208
+ const sessionID = input.sessionID ?? context.sessionID;
209
+ const subscription = await subscribe(ports.hooks, sessionID, input.topic).catch(rethrow("courier_subscribe"));
210
+ const receiving = await ports.receiving();
211
+ return {
212
+ content: subscribeText(sessionID, subscription.topic, receiving),
213
+ metadata: { sessionID, topic: subscription.topic, receiver: receiving },
214
+ };
215
+ },
216
+ });
217
+ tools.add({
218
+ name: "courier_unsubscribe",
219
+ options: { codemode: false },
220
+ description: "Stop webhook deliveries for a topic, or all of a session's topics, e.g. once its pull request is merged.",
221
+ input: UnsubscribeInput,
222
+ execute: async (input, context) => {
223
+ const sessionID = input.sessionID ?? context.sessionID;
224
+ const dropped = await unsubscribe(ports.hooks, sessionID, input.topic).catch(rethrow("courier_unsubscribe"));
225
+ return { content: unsubscribeText(sessionID, dropped), metadata: { sessionID, dropped } };
226
+ },
227
+ });
228
+ }
package/dist/watch.d.ts CHANGED
@@ -1,44 +1,17 @@
1
- import type { Plugin } from "@opencode/plugin";
2
- import { type PermissionAsked, type PermissionReplied, type Waiting } from "./relay.js";
3
- import { type RosterStorage } from "./roster.js";
4
- type Context = Plugin.Context;
1
+ import type { Hub, WatchPorts, WatchState } from "./hub.js";
2
+ import { type ExecutionError } from "./notices.js";
3
+ import { type FormCreated, type FormSettled, type PermissionAsked, type PermissionReplied } from "./relay.js";
4
+ export type { FormsTold, WatchPorts, WatchState } from "./hub.js";
5
5
  /** How long to wait before subscribing again after the event stream ended or broke. */
6
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
7
  /** The part of OpenCode's `session.execution.failed` event the notice is made from. */
28
8
  export interface ExecutionFailed {
29
9
  readonly id: string;
30
10
  readonly data: {
31
11
  readonly sessionID: string;
32
- readonly error: {
33
- readonly type: string;
34
- readonly message: string;
35
- readonly status?: number;
36
- };
12
+ readonly error: ExecutionError;
37
13
  };
38
14
  }
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
15
  /**
43
16
  * Tells the parent of a child whose turn failed, since a child that cannot reach its model cannot
44
17
  * call courier_send. Returns the parents told.
@@ -56,15 +29,33 @@ export declare function reportAsked(ports: WatchPorts, state: WatchState, event:
56
29
  */
57
30
  export declare function reportReplied(ports: WatchPorts, state: WatchState, event: PermissionReplied): Promise<string[]>;
58
31
  /**
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.
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.
35
+ */
36
+ export declare function reportForm(ports: WatchPorts, state: WatchState, event: FormCreated): Promise<string[]>;
37
+ /** 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. */
38
+ export declare function reportFormSettled(ports: WatchPorts, state: WatchState, event: FormSettled): Promise<string[]>;
39
+ /**
40
+ * Relays the requests that spawned sessions already wait on, in every loaded location, which the
41
+ * event stream does not repeat: those asked while it was down, before the watcher (re)subscribed.
61
42
  */
62
43
  export declare function relayPending(ports: WatchPorts, state: WatchState): Promise<void>;
63
44
  /**
64
45
  * 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
46
+ * fails, when it waits for a permission or on a form and when that is answered without them, and
66
47
  * noting for the question relay the question forms shown, which it waits for, and the locations
67
- * shutting down, whose withdrawn forms must not pass for dismissals.
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.
68
51
  */
69
52
  export declare function watchChildren(ports: WatchPorts, state: WatchState, signal: AbortSignal, retryMs?: number): Promise<void>;
70
- export {};
53
+ /**
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.
60
+ */
61
+ export declare function watchFromHub(hub: Pick<Hub, "subscribe">, state: WatchState, retryMs?: number): void;