opencode-courier 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +175 -274
  2. package/dist/index.js +47 -73
  3. package/dist/kernel/protocol.d.ts +20 -0
  4. package/dist/kernel/protocol.js +36 -0
  5. package/dist/kernel/tool.d.ts +5 -0
  6. package/dist/kernel/tool.js +5 -0
  7. package/dist/person/events.d.ts +23 -0
  8. package/dist/person/events.js +113 -0
  9. package/dist/person/form.d.ts +26 -0
  10. package/dist/person/form.js +64 -0
  11. package/dist/person/index.d.ts +17 -0
  12. package/dist/person/index.js +5 -0
  13. package/dist/{relay.d.ts → person/permission.d.ts} +27 -21
  14. package/dist/{relay.js → person/permission.js} +9 -5
  15. package/dist/person/ports.d.ts +58 -0
  16. package/dist/person/ports.js +2 -0
  17. package/dist/{question → person/question}/answer.d.ts +33 -4
  18. package/dist/{question → person/question}/answer.js +43 -8
  19. package/dist/person/question/index.d.ts +7 -0
  20. package/dist/{question → person/question}/index.js +3 -3
  21. package/dist/{question → person/question}/lifecycle.d.ts +1 -1
  22. package/dist/{question → person/question}/pure.d.ts +1 -2
  23. package/dist/{question → person/question}/pure.js +1 -1
  24. package/dist/{question → person/question}/relay.d.ts +1 -1
  25. package/dist/{question → person/question}/relay.js +14 -8
  26. package/dist/{question → person/question}/shared.d.ts +13 -3
  27. package/dist/person/question/shared.js +52 -0
  28. package/dist/person/question/types.d.ts +121 -0
  29. package/dist/person/question/types.js +1 -0
  30. package/dist/person/rpc-handlers.d.ts +160 -0
  31. package/dist/person/rpc-handlers.js +62 -0
  32. package/dist/person/rpc.d.ts +227 -0
  33. package/dist/person/rpc.js +70 -0
  34. package/dist/person/text.d.ts +68 -0
  35. package/dist/person/text.js +262 -0
  36. package/dist/person/tools.d.ts +20 -0
  37. package/dist/person/tools.js +55 -0
  38. package/dist/person/tui.d.ts +46 -0
  39. package/dist/{bounded.d.ts → platform/bounded.d.ts} +4 -0
  40. package/dist/{bounded.js → platform/bounded.js} +4 -0
  41. package/dist/platform/events.d.ts +34 -0
  42. package/dist/platform/events.js +121 -0
  43. package/dist/platform/hub.d.ts +195 -0
  44. package/dist/{hub.js → platform/hub.js} +27 -71
  45. package/dist/{version.js → platform/version.js} +1 -1
  46. package/dist/rpc.d.ts +2 -0
  47. package/dist/rpc.js +3 -0
  48. package/dist/scheduling/index.d.ts +6 -0
  49. package/dist/scheduling/index.js +5 -0
  50. package/dist/{later.d.ts → scheduling/later.d.ts} +7 -3
  51. package/dist/{later.js → scheduling/later.js} +12 -4
  52. package/dist/scheduling/text.d.ts +6 -0
  53. package/dist/scheduling/text.js +10 -0
  54. package/dist/scheduling/tools.d.ts +14 -0
  55. package/dist/scheduling/tools.js +48 -0
  56. package/dist/skill.d.ts +4 -0
  57. package/dist/skill.js +54 -1
  58. package/dist/{cleanup.d.ts → tree/cleanup.d.ts} +1 -27
  59. package/dist/{cleanup.js → tree/cleanup.js} +9 -16
  60. package/dist/tree/context.d.ts +15 -0
  61. package/dist/tree/context.js +34 -0
  62. package/dist/tree/domain.d.ts +101 -0
  63. package/dist/tree/domain.js +68 -0
  64. package/dist/{watch.d.ts → tree/events.d.ts} +10 -37
  65. package/dist/tree/events.js +204 -0
  66. package/dist/tree/forget.d.ts +24 -0
  67. package/dist/tree/forget.js +59 -0
  68. package/dist/tree/group.d.ts +82 -0
  69. package/dist/tree/group.js +203 -0
  70. package/dist/tree/index.d.ts +10 -0
  71. package/dist/tree/index.js +11 -0
  72. package/dist/tree/limits.d.ts +10 -0
  73. package/dist/{limits.js → tree/limits.js} +6 -55
  74. package/dist/tree/panel.d.ts +13 -0
  75. package/dist/tree/panel.js +75 -0
  76. package/dist/tree/ports.d.ts +276 -0
  77. package/dist/tree/ports.js +1 -0
  78. package/dist/tree/report.d.ts +23 -0
  79. package/dist/tree/report.js +33 -0
  80. package/dist/tree/roster.d.ts +27 -0
  81. package/dist/tree/roster.js +62 -0
  82. package/dist/tree/rpc-handlers.d.ts +201 -0
  83. package/dist/tree/rpc-handlers.js +20 -0
  84. package/dist/tree/rpc.d.ts +344 -0
  85. package/dist/tree/rpc.js +83 -0
  86. package/dist/tree/send.d.ts +14 -0
  87. package/dist/tree/send.js +99 -0
  88. package/dist/tree/spawn.d.ts +21 -0
  89. package/dist/tree/spawn.js +173 -0
  90. package/dist/tree/status.d.ts +128 -0
  91. package/dist/tree/status.js +62 -0
  92. package/dist/{stop.d.ts → tree/stop.d.ts} +1 -9
  93. package/dist/{stop.js → tree/stop.js} +9 -13
  94. package/dist/tree/store.d.ts +24 -0
  95. package/dist/tree/store.js +264 -0
  96. package/dist/{tree.d.ts → tree/subtree.d.ts} +5 -6
  97. package/dist/{tree.js → tree/subtree.js} +7 -5
  98. package/dist/tree/summary.d.ts +7 -0
  99. package/dist/tree/summary.js +35 -0
  100. package/dist/tree/text.d.ts +77 -0
  101. package/dist/tree/text.js +287 -0
  102. package/dist/tree/tools.d.ts +54 -0
  103. package/dist/{tools.js → tree/tools.js} +12 -142
  104. package/dist/tree/tui.d.ts +53 -0
  105. package/dist/tui.d.ts +3 -0
  106. package/dist/tui.js +1219 -0
  107. package/dist/webhook/index.d.ts +5 -0
  108. package/dist/webhook/index.js +3 -0
  109. package/dist/{webhook.d.ts → webhook/receiver.d.ts} +17 -23
  110. package/dist/{webhook.js → webhook/receiver.js} +58 -50
  111. package/dist/webhook/subscriptions.d.ts +29 -0
  112. package/dist/webhook/subscriptions.js +52 -0
  113. package/dist/webhook/text.d.ts +23 -0
  114. package/dist/webhook/text.js +82 -0
  115. package/dist/webhook/tools.d.ts +19 -0
  116. package/dist/webhook/tools.js +46 -0
  117. package/package.json +18 -7
  118. package/dist/courier.d.ts +0 -200
  119. package/dist/courier.js +0 -315
  120. package/dist/group.d.ts +0 -86
  121. package/dist/group.js +0 -247
  122. package/dist/hub.d.ts +0 -300
  123. package/dist/limits.d.ts +0 -65
  124. package/dist/notices.d.ts +0 -356
  125. package/dist/notices.js +0 -678
  126. package/dist/question/index.d.ts +0 -6
  127. package/dist/question/shared.js +0 -24
  128. package/dist/report.d.ts +0 -48
  129. package/dist/report.js +0 -88
  130. package/dist/roster.d.ts +0 -93
  131. package/dist/roster.js +0 -206
  132. package/dist/tools.d.ts +0 -94
  133. package/dist/watch.js +0 -474
  134. /package/dist/{question → person/question}/lifecycle.js +0 -0
  135. /package/dist/{question → person/question}/runtime.d.ts +0 -0
  136. /package/dist/{question → person/question}/runtime.js +0 -0
  137. /package/dist/{json.d.ts → platform/json.d.ts} +0 -0
  138. /package/dist/{json.js → platform/json.js} +0 -0
  139. /package/dist/{storage.d.ts → platform/storage.d.ts} +0 -0
  140. /package/dist/{storage.js → platform/storage.js} +0 -0
  141. /package/dist/{version.d.ts → platform/version.d.ts} +0 -0
package/dist/courier.d.ts DELETED
@@ -1,200 +0,0 @@
1
- import type { Plugin } from "@opencode/plugin";
2
- import { type Limits, type SpawnGate } from "./limits.js";
3
- import { type Artifacts, type Prompt, type Sent, type Status } from "./notices.js";
4
- import { type RosterEntry, type RosterStorage } from "./roster.js";
5
- type Context = Plugin.Context;
6
- /** The slice of the plugin context the courier tools use; tests pass a fake. */
7
- export interface CourierPorts {
8
- readonly session: Pick<Context["session"], "create" | "prompt" | "synthetic" | "get" | "context" | "interrupt">;
9
- readonly agent: Pick<Context["agent"], "get">;
10
- readonly worktree: Pick<Context["worktree"], "create" | "remove">;
11
- /** The project of the plugin's location; an isolated child's worktree is made in it, and removed from it. */
12
- readonly projectID: string;
13
- /** The commit a directory's checkout is on, or undefined; recorded as an isolated child's base. */
14
- readonly head: (directory: string) => Promise<string | undefined>;
15
- /** Whether a directory's checkout has uncommitted changes, or undefined when git cannot tell. */
16
- readonly dirty: (directory: string) => Promise<boolean | undefined>;
17
- readonly storage: RosterStorage;
18
- readonly directory: string;
19
- readonly now: () => number;
20
- /** The permission requests and questions a session waits on, wherever they are pending. */
21
- readonly pending: (sessionID: string) => Promise<ReadonlyArray<Pending>>;
22
- readonly limits: Limits;
23
- readonly gate: SpawnGate;
24
- /** The depths the `context` hook remembers; a parent's is dropped when it starts a child. */
25
- readonly roles: Map<string, number | null>;
26
- /** Has the scheduler deliver what is due now rather than at its next tick: a group a report has just completed. */
27
- readonly nudge: () => void;
28
- readonly log: (message: string) => void;
29
- }
30
- /** A request a session waits on until someone answers it: a permission request, or a question it asked. */
31
- export type Pending = {
32
- readonly type: "permission";
33
- readonly requestID: string;
34
- readonly action: string;
35
- readonly resources: ReadonlyArray<string>;
36
- readonly save?: ReadonlyArray<string>;
37
- } | {
38
- readonly type: "question";
39
- readonly requestID: string;
40
- readonly questions: ReadonlyArray<Prompt>;
41
- /** Its question call was cut off; the answer reaches it as a message. */
42
- readonly stopped?: true;
43
- };
44
- export interface SpawnInput {
45
- readonly task: string;
46
- readonly title?: string;
47
- readonly agent?: string;
48
- readonly isolate?: boolean;
49
- readonly group?: string;
50
- }
51
- export interface SendInput {
52
- readonly sessionID: string;
53
- readonly message: string;
54
- readonly status?: Status;
55
- readonly artifacts?: Artifacts;
56
- readonly queue?: boolean;
57
- }
58
- export interface StatusInput {
59
- readonly sessionID: string;
60
- }
61
- export interface ChildrenInput {
62
- readonly sessionID?: string;
63
- }
64
- /**
65
- * Creates a child session, hands it the task and returns at once; the child reports back with
66
- * courier_send. Refused past a limit, whether or not the `context` hook hid the tool.
67
- */
68
- export declare function spawn(ports: CourierPorts, parentID: string, input: SpawnInput): Promise<{
69
- uncommitted?: boolean | undefined;
70
- fromParent?: boolean | undefined;
71
- groupError?: string | undefined;
72
- rosterError?: string | undefined;
73
- group?: string | undefined;
74
- sessionID: string;
75
- directory: string;
76
- }>;
77
- /**
78
- * Drops a message into another session's inbox; OpenCode wakes that session if it is idle. From a spawned session
79
- * to the one that started it, it is its report or its progress (`noteReport`); a group member's final report is held (`hold`).
80
- */
81
- export declare function send(ports: CourierPorts, from: string, input: SendInput): Promise<Sent>;
82
- /** A one-off look at a session, for check-ins; not meant to be called in a loop. */
83
- export declare function status(ports: CourierPorts, input: StatusInput): Promise<Partial<{
84
- sessionID: string;
85
- title: string | undefined;
86
- parentID: string | undefined;
87
- outcome: "failed" | "interrupted" | "succeeded" | undefined;
88
- updated: number;
89
- idle: number | undefined;
90
- lastText: string | undefined;
91
- pending: readonly Pending[] | undefined;
92
- }>>;
93
- /** The text of a session's last reply, from its context's messages, or undefined when it has none. */
94
- export declare function lastReply(messages: Awaited<ReturnType<Context["session"]["context"]>>): string | undefined;
95
- /**
96
- * The sessions a parent started, each with what courier_status reports, or the error it gave; a child in a
97
- * group with the group's name and its standing there: `held`, `out`, how it left, `released` or `unknown`.
98
- */
99
- export declare function listChildren(ports: CourierPorts, parentID: string): Promise<({
100
- group: {
101
- name: string;
102
- report: string;
103
- };
104
- directory: string;
105
- isolated: boolean;
106
- created: number;
107
- sessionID?: string | undefined;
108
- title?: string | undefined;
109
- parentID?: string | undefined;
110
- outcome?: "failed" | "interrupted" | "succeeded" | undefined;
111
- updated?: number | undefined;
112
- idle?: number | undefined;
113
- lastText?: string | undefined;
114
- pending?: readonly Pending[] | undefined;
115
- } | {
116
- group?: undefined;
117
- directory: string;
118
- isolated: boolean;
119
- created: number;
120
- sessionID?: string | undefined;
121
- title?: string | undefined;
122
- parentID?: string | undefined;
123
- outcome?: "failed" | "interrupted" | "succeeded" | undefined;
124
- updated?: number | undefined;
125
- idle?: number | undefined;
126
- lastText?: string | undefined;
127
- pending?: readonly Pending[] | undefined;
128
- } | {
129
- error: string;
130
- group: {
131
- name: string;
132
- report: string;
133
- };
134
- directory: string;
135
- isolated: boolean;
136
- created: number;
137
- sessionID: string;
138
- title: string;
139
- } | {
140
- error: string;
141
- group?: undefined;
142
- directory: string;
143
- isolated: boolean;
144
- created: number;
145
- sessionID: string;
146
- title: string;
147
- })[]>;
148
- /** One roster entry as courier_children shows it: what courier_status reports with its directory and group standing, or the error it gave. */
149
- export declare function described(ports: CourierPorts, entry: RosterEntry): Promise<{
150
- group: {
151
- name: string;
152
- report: string;
153
- };
154
- directory: string;
155
- isolated: boolean;
156
- created: number;
157
- sessionID?: string | undefined;
158
- title?: string | undefined;
159
- parentID?: string | undefined;
160
- outcome?: "failed" | "interrupted" | "succeeded" | undefined;
161
- updated?: number | undefined;
162
- idle?: number | undefined;
163
- lastText?: string | undefined;
164
- pending?: readonly Pending[] | undefined;
165
- } | {
166
- group?: undefined;
167
- directory: string;
168
- isolated: boolean;
169
- created: number;
170
- sessionID?: string | undefined;
171
- title?: string | undefined;
172
- parentID?: string | undefined;
173
- outcome?: "failed" | "interrupted" | "succeeded" | undefined;
174
- updated?: number | undefined;
175
- idle?: number | undefined;
176
- lastText?: string | undefined;
177
- pending?: readonly Pending[] | undefined;
178
- } | {
179
- error: string;
180
- group: {
181
- name: string;
182
- report: string;
183
- };
184
- directory: string;
185
- isolated: boolean;
186
- created: number;
187
- sessionID: string;
188
- title: string;
189
- } | {
190
- error: string;
191
- group?: undefined;
192
- directory: string;
193
- isolated: boolean;
194
- created: number;
195
- sessionID: string;
196
- title: string;
197
- }>;
198
- /** A readable message for a failed courier call; OpenCode's own errors can carry an empty message. */
199
- export declare function describeFailure(tool: string, error: unknown): Error;
200
- export {};
package/dist/courier.js DELETED
@@ -1,315 +0,0 @@
1
- import { groupStanding, holdReport, isGroupName, joinGroup, leaveGroup, memberKey, memberOf, membershipOf, standingOf, unholdForBlocked, unholdReport } from "./group.js";
2
- import { admit } from "./limits.js";
3
- import { childBrief, childBranch, envelope, isStatus, reportBody, STATUSES } from "./notices.js";
4
- import { progressed, progressKey, prompted, settled, settledKey } from "./report.js";
5
- import { current, indexedEntry, record } from "./roster.js";
6
- function titleOf(task) {
7
- const line = task.trim().split("\n")[0] ?? "";
8
- return line.length > 60 ? `${line.slice(0, 57)}...` : line;
9
- }
10
- /**
11
- * The model a child runs on: its parent's, so it does not fall back to OpenCode's default, which
12
- * the parent may have been moved off for a reason. An agent that names its own model keeps it.
13
- */
14
- async function inheritedModel(ports, parentID, agent) {
15
- const parent = await ports.session.get({ sessionID: parentID });
16
- if (!parent.model || !agent)
17
- return parent.model;
18
- const named = await ports.agent.get({ agentID: agent, location: { directory: ports.directory } });
19
- return named.data.model ? undefined : parent.model;
20
- }
21
- /** Removes a worktree made for a child that was never created; one that cannot be removed stays. */
22
- async function dropWorktree(ports, directory) {
23
- try {
24
- await ports.worktree.remove({ projectID: ports.projectID, directory, force: false });
25
- }
26
- catch {
27
- // The spawn's own failure is the one to report.
28
- }
29
- }
30
- /**
31
- * Creates a child session, hands it the task and returns at once; the child reports back with
32
- * courier_send. Refused past a limit, whether or not the `context` hook hid the tool.
33
- */
34
- export async function spawn(ports, parentID, input) {
35
- // Some models send null, or an empty string, for an optional field they leave out.
36
- const group = typeof input.group === "string" ? input.group.trim() || undefined : (input.group ?? undefined);
37
- if (group !== undefined && !isGroupName(group))
38
- throw new Error(`group must be a name of 1 to 60 letters, digits, dots, dashes or underscores, not ${JSON.stringify(group)}.`);
39
- const admitted = await admit(ports, parentID);
40
- try {
41
- return await start(ports, parentID, input, admitted, group);
42
- }
43
- finally {
44
- admitted.release();
45
- }
46
- }
47
- async function start(ports, parentID, input, admitted, group) {
48
- const { worktree, handedDown, ...child } = await create(ports, parentID, input);
49
- admitted.reservation.sessionID = child.sessionID;
50
- const entry = {
51
- ...child,
52
- parentID,
53
- isolated: worktree !== undefined,
54
- createdAt: ports.now(),
55
- reports: "status",
56
- ...(worktree ? { source: ports.directory, project: ports.projectID } : {}),
57
- ...(group ? { group } : {}),
58
- };
59
- const { rosterError, groupError } = await enrol(ports, entry, group);
60
- ports.roles.delete(parentID);
61
- await handOver(ports, entry, admitted.depth, input.task, !rosterError, !rosterError && !groupError);
62
- return {
63
- sessionID: entry.sessionID,
64
- directory: entry.directory,
65
- ...(group && !rosterError && !groupError ? { group } : {}),
66
- ...(rosterError ? { rosterError } : {}),
67
- ...(groupError ? { groupError } : {}),
68
- ...(handedDown ? { fromParent: true } : {}),
69
- ...(handedDown?.uncommitted ? { uncommitted: true } : {}),
70
- };
71
- }
72
- /** Creates the child's session, in a worktree of its own if asked, which goes again if the session cannot be created. */
73
- async function create(ports, parentID, input) {
74
- // A failed lookup must not keep the child from starting; it then runs on OpenCode's default.
75
- const model = await inheritedModel(ports, parentID, input.agent).catch(() => undefined);
76
- const handedDown = input.isolate ? await parentWork(ports, parentID) : undefined;
77
- const worktree = input.isolate
78
- ? (await ports.worktree.create({ projectID: ports.projectID, ...(handedDown ? { from: handedDown.directory, branch: handedDown.head } : {}) })).directory
79
- : undefined;
80
- const base = worktree ? await ports.head(worktree) : undefined;
81
- const title = input.title ?? titleOf(input.task);
82
- const child = await ports.session
83
- .create({
84
- title,
85
- ...(input.agent ? { agent: input.agent } : {}),
86
- ...(model ? { model } : {}),
87
- ...(worktree ? { location: { directory: worktree } } : {}),
88
- metadata: { courier: { parentID } },
89
- })
90
- .catch(async (error) => {
91
- // No session will ever use the fresh worktree, and nothing records it, so it goes now.
92
- if (worktree)
93
- await dropWorktree(ports, worktree);
94
- throw error;
95
- });
96
- return { sessionID: child.id, title, directory: worktree ?? child.location.directory, worktree, handedDown, ...(base ? { base } : {}) };
97
- }
98
- /**
99
- * The worktree and HEAD an isolated parent hands its isolated child, by the reverse index alone; undefined for any
100
- * other parent. An unreadable HEAD fails the spawn rather than start the child without the parent's work.
101
- */
102
- async function parentWork(ports, parentID) {
103
- const parent = await indexedEntry(ports.storage, parentID);
104
- if (!parent?.isolated)
105
- return undefined;
106
- const head = await ports.head(parent.directory);
107
- if (!head)
108
- throw new Error(`the HEAD of your worktree ${parent.directory} could not be read, so an isolated session cannot start from it; nothing was started. Start it without isolate, or fix the worktree.`);
109
- const uncommitted = (await ports.dirty(parent.directory).catch(() => undefined)) === true;
110
- return { directory: parent.directory, head, uncommitted };
111
- }
112
- /**
113
- * Records the child on the roster, before the prompt, so a child that exists is on the roster even if prompting
114
- * fails, then in its group. A failed write must not keep the child from its task: each is reported, not thrown.
115
- */
116
- async function enrol(ports, entry, group) {
117
- const rosterError = await record(ports.storage, entry).then(() => undefined, (error) => describeFailure("roster", error).message);
118
- const groupError = group && !rosterError ? await join(ports, entry, group) : undefined;
119
- return { rosterError, groupError };
120
- }
121
- /**
122
- * Hands the child its task. `rostered` says whether its report state is kept, which a child off the roster has none
123
- * of, and `grouped` whether it is in its group; a child that never got its task leaves both, as it will never report.
124
- */
125
- async function handOver(ports, entry, depth, task, rostered, grouped) {
126
- // A baseline, until OpenCode's event for the prompt's delivery moves it on; written first, so it never overtakes that.
127
- if (rostered)
128
- await prompted(ports.storage, entry.sessionID, ports.now()).catch(() => undefined);
129
- try {
130
- await ports.session.prompt({ sessionID: entry.sessionID, text: childBrief(entry.parentID, task, depth, ports.limits, entry.isolated ? childBranch(entry.sessionID) : undefined) });
131
- }
132
- catch (error) {
133
- if (rostered)
134
- await settled(ports.storage, entry.sessionID, "failed", ports.now()).catch(() => undefined);
135
- if (grouped && entry.group)
136
- await leaveGroup(ports.storage, entry.parentID, entry.group, entry.sessionID, "failed", ports.now()).then((complete) => complete && ports.nudge(), (leaveError) => ports.log(`courier_spawn: ${entry.sessionID} could not leave group ${entry.group}: ${String(leaveError)}`));
137
- throw error;
138
- }
139
- }
140
- /**
141
- * Joins a child to its group; one that cannot be joined is recorded in no group, so it reports on its own,
142
- * and the result says so. Returns the error's message, if any.
143
- */
144
- async function join(ports, entry, group) {
145
- try {
146
- await joinGroup(ports.storage, entry.parentID, group, entry.sessionID, entry.title, ports.now());
147
- return undefined;
148
- }
149
- catch (error) {
150
- const { group: _, ...ungrouped } = entry;
151
- // A write that failed may have landed all the same: a member left there would hold its group up for good.
152
- await Promise.all([
153
- record(ports.storage, ungrouped).catch(() => undefined),
154
- ports.storage.remove(memberKey(entry.parentID, group, entry.sessionID)).catch(() => undefined),
155
- ]);
156
- return describeFailure("group", error).message;
157
- }
158
- }
159
- /**
160
- * Drops a message into another session's inbox; OpenCode wakes that session if it is idle. From a spawned session
161
- * to the one that started it, it is its report or its progress (`noteReport`); a group member's final report is held (`hold`).
162
- */
163
- export async function send(ports, from, input) {
164
- // Some models send null for an optional field they leave out.
165
- const status = input.status ?? undefined;
166
- if (status !== undefined && !isStatus(status))
167
- throw new Error(`status must be one of ${STATUSES.join(", ")}, not ${JSON.stringify(status)}.`);
168
- // Noted first: the delivery may wake the parent, whose turn may end before a later note.
169
- const noted = await noteReport(ports, from, input.sessionID, status).catch(() => undefined);
170
- try {
171
- const held = noted?.member && status && status !== "blocked" ? await hold(ports, noted.member, { at: noted.at, status, message: input.message, ...(input.artifacts ? { artifacts: input.artifacts } : {}) }) : undefined;
172
- if (held)
173
- return { status, report: true, held };
174
- const delivered = await ports.session.synthetic({
175
- sessionID: input.sessionID,
176
- text: envelope(from, reportBody(input.message, input.artifacts), status ? { status } : {}),
177
- description: `Message from ${from}`,
178
- metadata: { source: "courier", from, ...(status ? { status } : {}) },
179
- delivery: input.queue ? "queue" : "steer",
180
- });
181
- // A blocked report, once delivered, is the member's latest word: an earlier report held for it would be stale.
182
- if (noted?.member && status === "blocked")
183
- await unholdForBlocked(ports.storage, noted.member).catch(() => undefined);
184
- return { messageID: delivered.id, ...(status ? { status } : {}), ...(noted ? { report: noted.report } : {}) };
185
- }
186
- catch (error) {
187
- await noted?.undo().catch(() => undefined);
188
- throw error;
189
- }
190
- }
191
- /**
192
- * Notes `from`'s message to `to` when `to` started it: as its report when it carries a status, or when an earlier
193
- * release briefed `from`, else as progress. Returns which, when, how to take the note back, and `from`'s group membership.
194
- */
195
- async function noteReport(ports, from, to, status) {
196
- const entry = await indexedEntry(ports.storage, from);
197
- if (entry?.parentID !== to)
198
- return undefined;
199
- const report = status !== undefined || entry.reports !== "status";
200
- const key = report ? settledKey(from) : progressKey(from);
201
- const before = await ports.storage.get(key);
202
- const at = ports.now();
203
- await (report ? settled(ports.storage, from, "report", at, status) : progressed(ports.storage, from, at));
204
- // Only its own note: another courier_send of the session's may have noted its report since.
205
- const undo = async () => {
206
- const now = (await ports.storage.get(key));
207
- if (now?.at !== at || (report && now.by !== "report"))
208
- return;
209
- await (before === undefined ? ports.storage.remove(key) : ports.storage.set(key, before));
210
- };
211
- return { report, at, undo, member: status !== undefined ? await memberIn(ports, entry) : undefined };
212
- }
213
- /**
214
- * A child's membership of an open group of its parent's, by its roster entry; none once courier_cleanup dropped it. One that
215
- * cannot be read is dropped from the group, best effort, as the report then goes on its own and the group must not wait for it.
216
- */
217
- async function memberIn(ports, entry) {
218
- if (!entry.group)
219
- return undefined;
220
- try {
221
- const member = await membershipOf(ports.storage, entry.parentID, entry.group, entry.sessionID);
222
- return member?.left?.by === "dropped" ? undefined : member;
223
- }
224
- catch (error) {
225
- ports.log(`courier_send: ${entry.sessionID}'s place in group ${entry.group} could not be read, so its report goes on its own: ${String(error)}`);
226
- await ports.storage.remove(memberKey(entry.parentID, entry.group, entry.sessionID)).then(() => ports.nudge(), () => undefined);
227
- return undefined;
228
- }
229
- }
230
- /**
231
- * Holds a member's report with its group, and nudges the scheduler if that completes the group. Undefined
232
- * when it could not be held: the member is dropped from the group, which releases without it, and reports on its own.
233
- */
234
- async function hold(ports, member, report) {
235
- try {
236
- await holdReport(ports.storage, member, report);
237
- }
238
- catch (error) {
239
- // Neither held nor dropped, the group would wait for ever: the call fails, and the sender sends it again.
240
- await unholdReport(ports.storage, member).catch((dropError) => {
241
- throw new Error(`the report could not be held with group ${member.group} (${String(error)}), nor the member dropped from it (${String(dropError)}); send it again.`);
242
- });
243
- ports.nudge();
244
- return undefined;
245
- }
246
- // Held whatever the count gives; a count that cannot be read gets the nudge, as a tick that finds nothing due is cheap.
247
- const standing = await groupStanding(ports.storage, member.parentID, member.group).catch(() => undefined);
248
- if (standing?.complete !== false)
249
- ports.nudge();
250
- return { group: member.group, ...(standing ? { reported: standing.reported, members: standing.members } : {}) };
251
- }
252
- /** A one-off look at a session, for check-ins; not meant to be called in a loop. */
253
- export async function status(ports, input) {
254
- const [info, messages, pending] = await Promise.all([
255
- ports.session.get({ sessionID: input.sessionID }),
256
- ports.session.context({ sessionID: input.sessionID }),
257
- ports.pending(input.sessionID),
258
- ]);
259
- const lastText = lastReply(messages);
260
- return withoutUndefined({
261
- sessionID: info.id,
262
- title: info.title,
263
- parentID: info.parentID,
264
- outcome: info.outcome,
265
- updated: info.time.updated,
266
- idle: info.time.idle,
267
- lastText,
268
- pending: pending.length ? pending : undefined,
269
- });
270
- }
271
- /** The text of a session's last reply, from its context's messages, or undefined when it has none. */
272
- export function lastReply(messages) {
273
- const last = messages.findLast((message) => message.type === "assistant");
274
- return last?.type === "assistant"
275
- ? last.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")
276
- : undefined;
277
- }
278
- /**
279
- * The sessions a parent started, each with what courier_status reports, or the error it gave; a child in a
280
- * group with the group's name and its standing there: `held`, `out`, how it left, `released` or `unknown`.
281
- */
282
- export async function listChildren(ports, parentID) {
283
- const entries = await current(ports.storage, parentID, ports.now());
284
- return Promise.all(entries.map((entry) => described(ports, entry)));
285
- }
286
- /** One roster entry as courier_children shows it: what courier_status reports with its directory and group standing, or the error it gave. */
287
- export async function described(ports, entry) {
288
- const group = entry.group
289
- ? { group: { name: entry.group, report: await memberOf(ports.storage, entry.parentID, entry.group, entry.sessionID).then(standingOf, () => "unknown") } }
290
- : {};
291
- const roster = { directory: entry.directory, isolated: entry.isolated, created: entry.createdAt, ...group };
292
- try {
293
- return { ...(await status(ports, { sessionID: entry.sessionID })), ...roster };
294
- }
295
- catch (error) {
296
- return {
297
- sessionID: entry.sessionID,
298
- title: entry.title,
299
- ...roster,
300
- error: describeFailure("courier_status", error).message,
301
- };
302
- }
303
- }
304
- /** OpenCode leaves a tool call hanging when its metadata holds `undefined`, so results drop those keys. */
305
- function withoutUndefined(value) {
306
- return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
307
- }
308
- /** A readable message for a failed courier call; OpenCode's own errors can carry an empty message. */
309
- export function describeFailure(tool, error) {
310
- const tagged = error;
311
- const message = (typeof tagged?.message === "string" && tagged.message) ||
312
- [tagged?._tag, tagged?.sessionID].filter((item) => typeof item === "string").join(" ") ||
313
- String(error);
314
- return new Error(`${tool} failed: ${message}`);
315
- }
package/dist/group.d.ts DELETED
@@ -1,86 +0,0 @@
1
- import type { Plugin } from "@opencode/plugin";
2
- import { type HeldReport, type LeftMember } from "./notices.js";
3
- import { type Storage } from "./storage.js";
4
- type Context = Plugin.Context;
5
- export declare const isGroupName: (value: unknown) => value is string;
6
- export declare const memberKey: (parentID: string, group: string, sessionID: string) => string;
7
- /** The key prefix of a parent's groups, and of one group's members. */
8
- export declare const groupsPrefix: (parentID: string) => string;
9
- export declare const groupPrefix: (parentID: string, group: string) => string;
10
- /** A member of a group, as stored: its title for the release notice, and its report or how it left. */
11
- export interface Member {
12
- readonly title: string;
13
- readonly joinedAt: number;
14
- readonly report?: HeldReport;
15
- readonly left?: {
16
- readonly at: number;
17
- readonly by: LeftMember["by"];
18
- };
19
- }
20
- /** A member with where it is kept. */
21
- export interface Membership extends Member {
22
- readonly parentID: string;
23
- readonly group: string;
24
- readonly sessionID: string;
25
- }
26
- /** What is known of a member's standing: its report is held, still out, or it left, or it is no longer in an open group. */
27
- export type Standing = "held" | "out" | LeftMember["by"] | "released";
28
- /** A member's standing from its stored value, or `released` when there is none. */
29
- export declare function standingOf(member: Member | undefined): Standing;
30
- /** The member a session is in a parent's group, if the group is open and it is a member. */
31
- export declare function memberOf(storage: Pick<Storage, "get">, parentID: string, group: string, sessionID: string): Promise<Member | undefined>;
32
- /** Makes a spawned session a member of its parent's group, opening the group if it was not. */
33
- export declare function joinGroup(storage: Pick<Storage, "set">, parentID: string, group: string, sessionID: string, title: string, at: number): Promise<void>;
34
- /** Whether a parent has a complete group with a report in it, which the scheduler delivers once the parent's turn has ended. */
35
- export declare function hasCompleteGroup(storage: Pick<Storage, "scan">, parentID: string): Promise<boolean>;
36
- /** Holds a member's report with its group, in place of an earlier one, or of how it had left. */
37
- export declare function holdReport(storage: Pick<Storage, "set">, membership: Membership, report: HeldReport): Promise<void>;
38
- /** How many members of a group have reported and how many have not left, and whether the group is complete. */
39
- export declare function groupStanding(storage: Pick<Storage, "scan">, parentID: string, group: string): Promise<{
40
- reported: number;
41
- members: number;
42
- complete: boolean;
43
- }>;
44
- /** A session's membership of a parent's group, if it is a member; `out` says whether the group still waits for its report. */
45
- export declare function membershipOf(storage: Pick<Storage, "get">, parentID: string, group: string, sessionID: string): Promise<{
46
- parentID: string;
47
- group: string;
48
- sessionID: string;
49
- out: boolean;
50
- title: string;
51
- joinedAt: number;
52
- report?: HeldReport;
53
- left?: {
54
- readonly at: number;
55
- readonly by: LeftMember["by"];
56
- };
57
- } | undefined>;
58
- /**
59
- * Notes that a member left its group without a report: its turn failed or was interrupted, it was deleted or dropped. Read
60
- * again first: one whose report is held meanwhile keeps it, one that left keeps why. True when that completes the group.
61
- */
62
- export declare function markLeft(storage: Pick<Storage, "get" | "set" | "scan">, membership: Membership, by: LeftMember["by"], at: number): Promise<boolean>;
63
- /** `markLeft` for a member not read yet; undefined when it is in no open group. */
64
- export declare function leaveGroup(storage: Pick<Storage, "get" | "set" | "scan">, parentID: string, group: string, sessionID: string, by: LeftMember["by"], at: number): Promise<boolean | undefined>;
65
- /**
66
- * Puts a member whose report is held back among those out: its latest word to the parent was a blocked report. Read
67
- * again first: a group released meanwhile is not opened again, and a report held since is kept.
68
- */
69
- export declare function unholdForBlocked(storage: Pick<Storage, "get" | "set">, membership: Membership): Promise<void>;
70
- /** Drops a member from its group as its roster entry goes, unless its report is held: that still goes to the parent. True when dropped. */
71
- export declare function dropMember(storage: Pick<Storage, "get" | "remove">, parentID: string, group: string, sessionID: string): Promise<boolean>;
72
- /** Drops a member from its group whatever it holds: its report could not be held after all, so it goes on its own. */
73
- export declare function unholdReport(storage: Pick<Storage, "remove">, membership: Membership): Promise<void>;
74
- /** Drops every group of a parent, whose members' reports can no longer be delivered. */
75
- export declare function dropGroups(storage: Pick<Storage, "scan" | "remove">, parentID: string): Promise<void>;
76
- export interface GroupPorts {
77
- readonly storage: Storage;
78
- readonly session: Pick<Context["session"], "synthetic" | "get">;
79
- readonly log: (message: string) => void;
80
- }
81
- /**
82
- * Delivers every released group once, from the scheduler's tick: claimed in the shared `claimed`, re-read,
83
- * delivered and only then dropped, so a failed delivery is tried again at the next tick, and a crash in between delivers it again.
84
- */
85
- export declare function deliverReleased(ports: GroupPorts, claimed: Set<string>): Promise<void>;
86
- export {};