opencode-courier 0.2.3 → 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 (143) hide show
  1. package/README.md +198 -193
  2. package/dist/index.d.ts +4 -8
  3. package/dist/index.js +66 -85
  4. package/dist/kernel/protocol.d.ts +20 -0
  5. package/dist/kernel/protocol.js +36 -0
  6. package/dist/kernel/tool.d.ts +5 -0
  7. package/dist/kernel/tool.js +5 -0
  8. package/dist/person/events.d.ts +23 -0
  9. package/dist/person/events.js +113 -0
  10. package/dist/person/form.d.ts +26 -0
  11. package/dist/person/form.js +64 -0
  12. package/dist/person/index.d.ts +17 -0
  13. package/dist/person/index.js +5 -0
  14. package/dist/{relay.d.ts → person/permission.d.ts} +29 -25
  15. package/dist/{relay.js → person/permission.js} +11 -9
  16. package/dist/person/ports.d.ts +58 -0
  17. package/dist/person/ports.js +2 -0
  18. package/dist/{question → person/question}/answer.d.ts +33 -4
  19. package/dist/{question → person/question}/answer.js +49 -17
  20. package/dist/person/question/index.d.ts +7 -0
  21. package/dist/{question → person/question}/index.js +3 -3
  22. package/dist/person/question/lifecycle.d.ts +43 -0
  23. package/dist/{question → person/question}/lifecycle.js +10 -19
  24. package/dist/{question → person/question}/pure.d.ts +4 -6
  25. package/dist/{question → person/question}/pure.js +3 -4
  26. package/dist/{question → person/question}/relay.d.ts +1 -1
  27. package/dist/{question → person/question}/relay.js +27 -35
  28. package/dist/{question → person/question}/runtime.d.ts +2 -3
  29. package/dist/{question → person/question}/runtime.js +2 -3
  30. package/dist/{question → person/question}/shared.d.ts +13 -3
  31. package/dist/person/question/shared.js +52 -0
  32. package/dist/person/question/types.d.ts +121 -0
  33. package/dist/person/question/types.js +1 -0
  34. package/dist/person/rpc-handlers.d.ts +160 -0
  35. package/dist/person/rpc-handlers.js +62 -0
  36. package/dist/person/rpc.d.ts +227 -0
  37. package/dist/person/rpc.js +70 -0
  38. package/dist/person/text.d.ts +68 -0
  39. package/dist/{notices.js → person/text.js} +53 -190
  40. package/dist/person/tools.d.ts +20 -0
  41. package/dist/person/tools.js +55 -0
  42. package/dist/person/tui.d.ts +46 -0
  43. package/dist/platform/bounded.d.ts +12 -0
  44. package/dist/{bounded.js → platform/bounded.js} +6 -4
  45. package/dist/platform/events.d.ts +34 -0
  46. package/dist/platform/events.js +121 -0
  47. package/dist/platform/hub.d.ts +195 -0
  48. package/dist/{hub.js → platform/hub.js} +78 -119
  49. package/dist/platform/json.d.ts +8 -0
  50. package/dist/platform/json.js +15 -0
  51. package/dist/platform/version.d.ts +16 -0
  52. package/dist/{version.js → platform/version.js} +5 -11
  53. package/dist/rpc.d.ts +2 -0
  54. package/dist/rpc.js +3 -0
  55. package/dist/scheduling/index.d.ts +6 -0
  56. package/dist/scheduling/index.js +5 -0
  57. package/dist/{later.d.ts → scheduling/later.d.ts} +13 -9
  58. package/dist/{later.js → scheduling/later.js} +18 -9
  59. package/dist/scheduling/text.d.ts +6 -0
  60. package/dist/scheduling/text.js +10 -0
  61. package/dist/scheduling/tools.d.ts +14 -0
  62. package/dist/scheduling/tools.js +48 -0
  63. package/dist/skill.d.ts +17 -0
  64. package/dist/skill.js +62 -0
  65. package/dist/{cleanup.d.ts → tree/cleanup.d.ts} +5 -26
  66. package/dist/{cleanup.js → tree/cleanup.js} +20 -15
  67. package/dist/tree/context.d.ts +15 -0
  68. package/dist/tree/context.js +34 -0
  69. package/dist/tree/domain.d.ts +101 -0
  70. package/dist/tree/domain.js +68 -0
  71. package/dist/tree/events.d.ts +71 -0
  72. package/dist/tree/events.js +204 -0
  73. package/dist/tree/forget.d.ts +24 -0
  74. package/dist/tree/forget.js +59 -0
  75. package/dist/tree/group.d.ts +82 -0
  76. package/dist/tree/group.js +203 -0
  77. package/dist/tree/index.d.ts +10 -0
  78. package/dist/tree/index.js +11 -0
  79. package/dist/tree/limits.d.ts +10 -0
  80. package/dist/tree/limits.js +60 -0
  81. package/dist/tree/panel.d.ts +13 -0
  82. package/dist/tree/panel.js +75 -0
  83. package/dist/tree/ports.d.ts +276 -0
  84. package/dist/tree/ports.js +1 -0
  85. package/dist/tree/report.d.ts +23 -0
  86. package/dist/tree/report.js +33 -0
  87. package/dist/tree/roster.d.ts +27 -0
  88. package/dist/tree/roster.js +62 -0
  89. package/dist/tree/rpc-handlers.d.ts +201 -0
  90. package/dist/tree/rpc-handlers.js +20 -0
  91. package/dist/tree/rpc.d.ts +344 -0
  92. package/dist/tree/rpc.js +83 -0
  93. package/dist/tree/send.d.ts +14 -0
  94. package/dist/tree/send.js +99 -0
  95. package/dist/tree/spawn.d.ts +21 -0
  96. package/dist/tree/spawn.js +173 -0
  97. package/dist/tree/status.d.ts +128 -0
  98. package/dist/tree/status.js +62 -0
  99. package/dist/tree/stop.d.ts +33 -0
  100. package/dist/tree/stop.js +116 -0
  101. package/dist/tree/store.d.ts +24 -0
  102. package/dist/tree/store.js +264 -0
  103. package/dist/tree/subtree.d.ts +101 -0
  104. package/dist/tree/subtree.js +60 -0
  105. package/dist/tree/summary.d.ts +7 -0
  106. package/dist/tree/summary.js +35 -0
  107. package/dist/tree/text.d.ts +77 -0
  108. package/dist/tree/text.js +287 -0
  109. package/dist/tree/tools.d.ts +54 -0
  110. package/dist/tree/tools.js +167 -0
  111. package/dist/tree/tui.d.ts +53 -0
  112. package/dist/tui.d.ts +3 -0
  113. package/dist/tui.js +1219 -0
  114. package/dist/webhook/index.d.ts +5 -0
  115. package/dist/webhook/index.js +3 -0
  116. package/dist/{webhook.d.ts → webhook/receiver.d.ts} +25 -36
  117. package/dist/{webhook.js → webhook/receiver.js} +66 -67
  118. package/dist/webhook/subscriptions.d.ts +29 -0
  119. package/dist/webhook/subscriptions.js +52 -0
  120. package/dist/webhook/text.d.ts +23 -0
  121. package/dist/webhook/text.js +82 -0
  122. package/dist/webhook/tools.d.ts +19 -0
  123. package/dist/webhook/tools.js +46 -0
  124. package/package.json +18 -7
  125. package/dist/bounded.d.ts +0 -10
  126. package/dist/courier.d.ts +0 -95
  127. package/dist/courier.js +0 -131
  128. package/dist/hub.d.ts +0 -325
  129. package/dist/json.d.ts +0 -4
  130. package/dist/json.js +0 -4
  131. package/dist/notices.d.ts +0 -246
  132. package/dist/question/index.d.ts +0 -6
  133. package/dist/question/lifecycle.d.ts +0 -52
  134. package/dist/question/shared.js +0 -24
  135. package/dist/roster.d.ts +0 -81
  136. package/dist/roster.js +0 -175
  137. package/dist/tools.d.ts +0 -74
  138. package/dist/tools.js +0 -228
  139. package/dist/version.d.ts +0 -22
  140. package/dist/watch.d.ts +0 -61
  141. package/dist/watch.js +0 -289
  142. /package/dist/{storage.d.ts → platform/storage.d.ts} +0 -0
  143. /package/dist/{storage.js → platform/storage.js} +0 -0
@@ -0,0 +1,287 @@
1
+ /** The tree's model-facing texts: the child brief, the role part, refusals, reports, notices and tool results. */
2
+ import { obj, str } from "../platform/json.js";
3
+ import { clipText, defuse, END_TURN, SKILL_ID, SPLIT_LINE, STATUSES } from "../kernel/protocol.js";
4
+ // The envelope and the child brief.
5
+ /** How a text names the statuses a report may carry. */
6
+ const STATUS_LIST = `${STATUSES.slice(0, -1).join(", ")} or ${STATUSES.at(-1)}`;
7
+ /** The branch an isolated child commits its work on, and names in the `branch` of its report. */
8
+ export const childBranch = (sessionID) => `courier/${sessionID}`;
9
+ /** What a spawned session at `depth` is told; `depth` 1 is a child of a session nobody spawned. `branch` is for an isolated one. */
10
+ export function childBrief(parentID, task, depth, limits, branch) {
11
+ return [
12
+ `You were started by session ${parentID} through opencode-courier.`,
13
+ "",
14
+ `When you finish, send exactly one report: call courier_send with sessionID "${parentID}", a short account of what you did and ` +
15
+ "status done, partial (say what is left and why) or failed (say what went wrong). Add artifacts where there are any: " +
16
+ "branch, commits, files, checks (each command you ran and its result).",
17
+ "If you need a decision from the session that started you, which you cannot make yourself, send status blocked with a " +
18
+ "concrete ask, then end your turn and wait for its answer; never end quietly.",
19
+ "A report with a status wakes the parent and is the only way it hears from you, so do not end without sending one. " +
20
+ "A message without a status is progress, not your report: if you end your turn without a report, the parent is told so.",
21
+ "If you need the person to decide something, use your question tool; it reaches them through the session that started you.",
22
+ "",
23
+ ...(branch ? [...isolationRule(branch, depth < limits.maxDepth), ""] : []),
24
+ ...splitRule(parentID, depth, limits),
25
+ "",
26
+ "Task:",
27
+ task,
28
+ ].join("\n");
29
+ }
30
+ function isolationRule(branch, mayPass) {
31
+ return [
32
+ "You work in a git worktree of your own, on a detached HEAD. Commit your work on the branch " +
33
+ `${branch}: create it first (git switch -c ${branch}), and name it in the branch of your report's artifacts, so the ` +
34
+ "session that started you can merge it. Uncommitted work is not in your report.",
35
+ ...(mayPass
36
+ ? [
37
+ `If you split your task and start a session with isolate: true, commit first: its worktree starts from your current HEAD, ` +
38
+ "not from your uncommitted changes. Merge each branch it reports into yours before you report.",
39
+ ]
40
+ : []),
41
+ ];
42
+ }
43
+ function splitRule(parentID, depth, limits) {
44
+ if (depth >= limits.maxDepth)
45
+ return [`You are at depth ${depth}, the deepest the session tree goes, so you cannot start sessions: do the task yourself.`];
46
+ return [
47
+ `You are at depth ${depth} of a session tree that goes at most ${limits.maxDepth} deep. Split your task or do it yourself:`,
48
+ "- Split it when it has 2 or more independent parts, each substantial and touching separate files or areas: start one " +
49
+ `session per part with courier_spawn, at most ${limits.maxChildren} of yours running at once and ${limits.maxTotal} in the whole tree.`,
50
+ "- Do it yourself when it is small, sequential or tightly coupled. Never start exactly one session.",
51
+ `- Say which in one line of your reply: ${SPLIT_LINE}.`,
52
+ "- If you split, you orchestrate: end your turn while they work, check and integrate each part yourself when it " +
53
+ `reports (never hand that checking to another session), then send ${parentID} one combined report.`,
54
+ ];
55
+ }
56
+ /** How every part the context hook adds begins, so a second copy of the plugin does not add its own. */
57
+ export const ROLE_PREFIX = "opencode-courier role:";
58
+ /**
59
+ * The system part naming a session's place in its tree: `depth` 0 for a session nobody spawned that
60
+ * has started one, `null` for one that has started none, which is pointed to the skill. The same on every request.
61
+ */
62
+ export function rolePart(depth, limits) {
63
+ const { maxDepth, maxChildren, maxTotal } = limits;
64
+ const budget = `At most ${maxChildren} of the sessions you start run at once, and ${maxTotal} in the whole tree.`;
65
+ if (depth === null)
66
+ return (`${ROLE_PREFIX} root, no sessions started yet. Before a task that touches several files or areas, load the ` +
67
+ `${SKILL_ID} skill and decide whether to split it across sessions (doing it yourself is fine), and say ` +
68
+ `which in one line of your reply: ${SPLIT_LINE}`);
69
+ if (depth === 0)
70
+ return (`${ROLE_PREFIX} root orchestrator. You started sessions with courier_spawn, and they may start their own, ` +
71
+ `down to ${maxDepth} levels below you. ${budget} Check and integrate what each one reports yourself.`);
72
+ if (depth < maxDepth)
73
+ return (`${ROLE_PREFIX} sub-orchestrator, at depth ${depth} of at most ${maxDepth}. Split your task with courier_spawn or ` +
74
+ `do it yourself, as your brief says; sessions can be started ${count(maxDepth - depth, "level")} below you. ${budget}`);
75
+ return (`${ROLE_PREFIX} leaf, at depth ${depth}, the deepest the session tree goes. You cannot start sessions: do your ` +
76
+ "task yourself and report with courier_send to the session that started you.");
77
+ }
78
+ // courier_spawn's refusals, one per limit.
79
+ export const depthRefusal = (depth, maxDepth) => `Not started: this session is at depth ${depth} of its session tree, and the tree goes at most ${maxDepth} deep ` +
80
+ "(maxDepth), so it cannot start sessions. Do the task yourself, and report with courier_send.";
81
+ /** How a refusal names sessions that count against a limit: those running, and those that have not reported yet. */
82
+ const live = (n) => (n === 1 ? "is running or has not reported yet" : "are running or have not reported yet");
83
+ export const childrenRefusal = (running, maxChildren) => `Not started: ${count(running, "session")} you started ${live(running)}, the most allowed at once ` +
84
+ `(maxChildren ${maxChildren}). Do this part yourself, or end your turn and start it once one of them has reported.`;
85
+ export const totalRefusal = (running, maxTotal) => `Not started: ${count(running, "session")} in this session tree ${live(running)}, the most allowed at once ` +
86
+ `(maxTotal ${maxTotal}). Do this part yourself, or end your turn and try again once a report has arrived.`;
87
+ /** The strings of a list given as artifacts, whatever else the model put in it. */
88
+ const strings = (value) => (Array.isArray(value) ? value.flatMap((item) => (typeof item === "string" ? [item.trim()] : [])) : []).filter(Boolean);
89
+ /** A list's line in the artifacts block, or none when it is empty. */
90
+ const listed = (name, items) => (items.length ? [`- ${name}: ${items.join(", ")}`] : []);
91
+ /**
92
+ * The body of a report: its text, then the artifacts in a fixed layout, the same for every report,
93
+ * so a parent finds the branch, the commits, the files and the checks without reading prose.
94
+ */
95
+ export function reportBody(message, artifacts) {
96
+ const given = obj(artifacts);
97
+ const checks = (Array.isArray(given.checks) ? given.checks : []).flatMap((check) => {
98
+ const command = str(obj(check).command)?.trim();
99
+ return command ? [` - ${command}: ${str(obj(check).result)?.trim() || "(no result given)"}`] : [];
100
+ });
101
+ const lines = [
102
+ ...listed("branch", strings([given.branch])),
103
+ ...listed("commits", strings(given.commits)),
104
+ ...listed("files", strings(given.files)),
105
+ ...(checks.length ? ["- checks:", ...checks] : []),
106
+ ];
107
+ return lines.length ? [message, "", "Artifacts:", ...lines].join("\n") : message;
108
+ }
109
+ // Tool results.
110
+ export function spawnText(child) {
111
+ const warning = (child.fromParent ? " Its worktree starts from the commit your own worktree is on." : "") +
112
+ (child.uncommitted
113
+ ? " Your worktree has uncommitted changes, which it does not see: commit them and tell it, or send them to it, if it needs them."
114
+ : "") +
115
+ (child.rosterError ? ` It is not on your courier_children list: ${child.rosterError}` : "") +
116
+ (child.groupError ? ` It is in no group, so its report comes on its own: ${child.groupError}` : "");
117
+ const reports = child.group
118
+ ? `Its report is held with group "${child.group}": once every session you started in that group has reported and your ` +
119
+ "turn has ended, you get their reports in one message, which starts a new turn for you; a blocked report, a question " +
120
+ "or a failed turn still reaches you at once."
121
+ : "It will report back with courier_send, which starts a new turn for you.";
122
+ return (`Started session ${child.sessionID} in ${child.directory}. ${reports} Once you have started every session you need, ` +
123
+ `end your turn: reply without calling more tools. That does not drop the task; you carry on with it when the reports arrive.${warning}`);
124
+ }
125
+ /** courier_send's result: for a message to the session that started the sender, whether it was its report. */
126
+ export function sendText(sessionID, sent = {}) {
127
+ const status = sent.status ? `, status ${sent.status}` : "";
128
+ if (sent.held) {
129
+ const { group, reported, members } = sent.held;
130
+ const counted = reported !== undefined && members !== undefined;
131
+ let when = `once ${sessionID}'s turn has ended, yours being the last.`;
132
+ if (!counted || reported < members) {
133
+ const have = counted ? ` (${reported} of ${members} have)` : "";
134
+ when = `once every member of the group has reported${have}. Nothing more is needed from you for it.`;
135
+ }
136
+ return `Recorded as your report${status}. It is held with group "${group}" and goes to ${sessionID} in one message with the group's other reports, ${when} ${END_TURN}`;
137
+ }
138
+ if (sent.report === true)
139
+ return `Delivered to ${sessionID} as your report${status}. ${END_TURN}`;
140
+ if (sent.report === false)
141
+ return `Delivered to ${sessionID}. Without a status it is progress, not your report: when you finish, send one with status ${STATUS_LIST}.`;
142
+ return `Delivered to ${sessionID}.`;
143
+ }
144
+ /** courier_status's result: the session's state, as JSON. */
145
+ export const statusText = (status) => JSON.stringify(status, null, 2);
146
+ export const childrenText = (listed) => listed.length ? JSON.stringify(listed, null, 2) : "No sessions started with courier_spawn.";
147
+ export function cleanupText(result) {
148
+ if (result.outcome === "kept")
149
+ return `Kept the worktree ${result.directory} of ${result.sessionID}: it has ${result.reason}. Commit or branch what you want to keep, or call courier_cleanup again with force: true to discard it.`;
150
+ const done = {
151
+ removed: `Removed the worktree ${result.directory} of ${result.sessionID}.`,
152
+ gone: `The worktree ${result.directory} of ${result.sessionID} was already gone; dropped it from courier_children.`,
153
+ forgotten: `${result.sessionID} ran in ${result.directory}, with no worktree of its own to remove; dropped it from courier_children.`,
154
+ }[result.outcome];
155
+ if (!result.group)
156
+ return done;
157
+ return `${done} Dropped it from group "${result.group}" without a report: the group's other reports reach you once they are in and your turn has ended, the message naming it, and a report it sends after all comes on its own.`;
158
+ }
159
+ /** courier_tree's result: the sessions under a session, drawn depth first, as JSON. */
160
+ export function treeText(tree) {
161
+ if (!tree.sessions.length)
162
+ return "No sessions started with courier_spawn.";
163
+ const note = tree.truncated === undefined ? "" : `\nOnly the first ${tree.truncated} sessions are listed (maxTotal); courier_tree on one of them lists what is under it.`;
164
+ return JSON.stringify(tree, null, 2) + note;
165
+ }
166
+ /** What courier_stop did to each session, and to the messages and worktrees. */
167
+ export function stopText(result) {
168
+ const interrupted = result.stopped.filter((one) => one.outcome === "interrupted").length;
169
+ const failed = result.stopped.filter((one) => one.outcome === "failed");
170
+ return [
171
+ `Stopped ${result.sessionID} and ${count(result.stopped.length - 1, "session")} under it, deepest first: ${interrupted} had a turn running and was interrupted, ` +
172
+ `${result.stopped.filter((one) => one.outcome === "idle" || one.outcome === "gone").length} did not run.`,
173
+ ...result.stopped.map((one) => `- ${one.sessionID}: ${one.outcome}${one.error ? " (" + one.error + ")" : ""}`),
174
+ ...(failed.length ? ["Those that failed may still run: try courier_stop again."] : []),
175
+ result.cancelled.length
176
+ ? `Cancelled ${count(result.cancelled.length, "scheduled message")}: ${result.cancelled.join(", ")}.`
177
+ : "No scheduled messages were pending for them.",
178
+ ...(result.told ? [`Told ${result.told}, which started ${result.sessionID}, that it was stopped.`] : []),
179
+ ...(result.cleanup ? worktreeLines(result.cleanup) : []),
180
+ ].join("\n");
181
+ }
182
+ const FORCE_HINT = " Commit or branch what you want to keep, or call courier_cleanup again with force: true to discard it.";
183
+ /** courier_stop's lines on the worktrees it was asked to remove. */
184
+ function worktreeLines(cleanup) {
185
+ if (!cleanup.length)
186
+ return ["None of them ran in a worktree of its own."];
187
+ return cleanup.map((one) => one.outcome === "failed"
188
+ ? `Worktree of ${one.sessionID}: not removed (${one.error}).`
189
+ : cleanupText(one).replace(FORCE_HINT, " Kept; courier_cleanup with force: true discards it."));
190
+ }
191
+ /** What the parent of a stopped session is told when the stop was ordered by a session above both. */
192
+ export const stoppedNotice = (title, by) => [
193
+ `This session, "${title}", which you started with courier_spawn, was stopped by ${by}, a session above you, together with the sessions under it.`,
194
+ "It was not a failure and it will not report back. Carry on without it, or start a replacement if you still need what it was doing.",
195
+ ].join("\n");
196
+ /** Why a worktree in this state must be kept, or undefined when removing it loses nothing. */
197
+ export function keepReason(state) {
198
+ const reasons = [
199
+ state.changes.length ? `${count(state.changes.length, "uncommitted change")} (${preview(state.changes)})` : "",
200
+ state.commits.length
201
+ ? `${count(state.commits.length, "commit")} on no branch, tag or remote (${preview(state.commits)})`
202
+ : "",
203
+ ].filter(Boolean);
204
+ return reasons.length ? reasons.join(" and ") : undefined;
205
+ }
206
+ function count(n, noun) {
207
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
208
+ }
209
+ function preview(items) {
210
+ return items.length > 5 ? `${items.slice(0, 5).join(", ")}, ...` : items.join(", ");
211
+ }
212
+ // Turn failures.
213
+ export function failureNotice(title, error, group) {
214
+ const status = error.status === undefined ? "" : `, status ${error.status}`;
215
+ return [
216
+ `This session, "${title}", which you started with courier_spawn, failed: ${error.message} (${error.type}${status}).`,
217
+ "Its turn ended without finishing, so it will not report back on its own.",
218
+ "Message it with courier_send to have it try again, start a replacement, or carry on without it.",
219
+ ...(group
220
+ ? [
221
+ `It is a member of group "${group}", which no longer waits for it: its other members' reports reach you once they have ` +
222
+ "reported and your turn has ended, and if it reports after all before then, its report joins them.",
223
+ ]
224
+ : []),
225
+ ].join("\n");
226
+ }
227
+ /** Longest last reply a notice that a session ended its turn without reporting quotes. */
228
+ const MAX_REPLY = 2000;
229
+ /**
230
+ * What the parent is told when a session it started ends its turn without a report to it;
231
+ * `progressed` when it messaged the parent without a status since its last prompt.
232
+ */
233
+ export function silentNotice(title, lastText, progressed = false, group) {
234
+ const reply = lastText?.trim();
235
+ return [
236
+ `This session, "${title}", which you started with courier_spawn, ended its turn without reporting back with courier_send, and does nothing more on its own.`,
237
+ ...(progressed
238
+ ? [`Its last message to you had no status, so it was progress, not its report: a report carries status ${STATUS_LIST}.`]
239
+ : []),
240
+ ...(reply ? ["Its last reply:", defuse(clipText(reply, MAX_REPLY))] : ["It ended without a reply."]),
241
+ "",
242
+ "Decide what it needs: message it with courier_send to have it carry on, or to report if its last reply is what you needed; or start a replacement.",
243
+ "Until it reports, it counts toward your limits on the sessions you run at once.",
244
+ ...(group
245
+ ? [
246
+ `It is a member of group "${group}", whose other reports are held until it reports: have it report, as above, so the group ` +
247
+ "reaches you, or, if you no longer need its report, drop it with courier_cleanup, which releases the group without it.",
248
+ ]
249
+ : []),
250
+ ].join("\n");
251
+ }
252
+ // Join groups.
253
+ /** How a release names each status among its reports: `2 done, 1 failed`. */
254
+ function statusCounts(reports) {
255
+ return STATUSES.flatMap((status) => {
256
+ const n = reports.filter((report) => report.status === status).length;
257
+ return n ? [`${n} ${status}`] : [];
258
+ }).join(", ");
259
+ }
260
+ const LEFT_HOW = {
261
+ failed: "its turn failed",
262
+ interrupted: "its turn was interrupted",
263
+ deleted: "it was deleted",
264
+ dropped: "you dropped it with courier_cleanup",
265
+ };
266
+ /**
267
+ * What the parent gets when a group it named is released: every report, with its status, text and artifacts in
268
+ * `reportBody`'s layout, and the members that left without one, in the order they joined.
269
+ */
270
+ export function groupNotice(group, reports, left = []) {
271
+ const total = reports.length + left.length;
272
+ const listed = reports.flatMap((report, index) => [
273
+ "",
274
+ `[${index + 1}/${reports.length}] ${report.sessionID} "${report.title}": ${report.status}`,
275
+ reportBody(report.message, report.artifacts),
276
+ ]);
277
+ const named = left.map((member) => `${member.sessionID} "${member.title}" (${LEFT_HOW[member.by]})`).join(", ");
278
+ const without = left.length ? ["", `Without a report: ${named}.`] : [];
279
+ const missing = left.length ? `, ${count(left.length, "member")} without a report, named at the end` : "";
280
+ return [
281
+ `Group "${group}" is complete: ${count(reports.length, "report")} of ${count(total, "member")} (${statusCounts(reports)})${missing}. Each report follows, with its status.`,
282
+ ...listed,
283
+ ...without,
284
+ "",
285
+ "Check and integrate each one yourself. The sessions that reported are idle; message one with courier_send if it has more to do.",
286
+ ].join("\n");
287
+ }
@@ -0,0 +1,54 @@
1
+ import { Schema } from "effect";
2
+ import { type ToolEditor } from "../kernel/tool.js";
3
+ import type { CleanupPorts, CourierPorts, StopPorts } from "./ports.js";
4
+ /**
5
+ * The tree's courier tools: their input schemas, descriptions, and what each returns. The descriptions are
6
+ * model-facing like the texts in `text.ts`, and snapshotted with them in `test/notices.test.ts`.
7
+ */
8
+ export declare const SpawnInput: Schema.Struct<{
9
+ readonly task: Schema.String;
10
+ readonly title: Schema.optional<Schema.String>;
11
+ readonly agent: Schema.optional<Schema.String>;
12
+ readonly isolate: Schema.optional<Schema.Boolean>;
13
+ readonly group: Schema.optional<Schema.String>;
14
+ }>;
15
+ export declare const SendInput: Schema.Struct<{
16
+ readonly sessionID: Schema.String;
17
+ readonly message: Schema.String;
18
+ readonly status: Schema.optional<Schema.Literals<readonly ["done", "partial", "blocked", "failed"]>>;
19
+ readonly artifacts: Schema.optional<Schema.Struct<{
20
+ readonly branch: Schema.optional<Schema.String>;
21
+ readonly commits: Schema.optional<Schema.$Array<Schema.String>>;
22
+ readonly files: Schema.optional<Schema.$Array<Schema.String>>;
23
+ readonly checks: Schema.optional<Schema.$Array<Schema.Struct<{
24
+ readonly command: Schema.String;
25
+ readonly result: Schema.String;
26
+ }>>>;
27
+ }>>;
28
+ readonly queue: Schema.optional<Schema.Boolean>;
29
+ }>;
30
+ export declare const StatusInput: Schema.Struct<{
31
+ readonly sessionID: Schema.String;
32
+ }>;
33
+ export declare const ChildrenInput: Schema.Struct<{
34
+ readonly sessionID: Schema.optional<Schema.String>;
35
+ }>;
36
+ export declare const TreeInput: Schema.Struct<{
37
+ readonly sessionID: Schema.optional<Schema.String>;
38
+ }>;
39
+ export declare const StopInput: Schema.Struct<{
40
+ readonly sessionID: Schema.String;
41
+ readonly cleanup: Schema.optional<Schema.Boolean>;
42
+ }>;
43
+ export declare const CleanupInput: Schema.Struct<{
44
+ readonly sessionID: Schema.String;
45
+ readonly force: Schema.optional<Schema.Boolean>;
46
+ }>;
47
+ /** What the tree's tools run on: one plugin instance's ports. */
48
+ export interface TreeToolPorts {
49
+ readonly courier: CourierPorts;
50
+ readonly cleanup: CleanupPorts;
51
+ readonly stop: StopPorts;
52
+ }
53
+ /** Adds the tree's courier tools, each a direct tool rather than a code-mode one. */
54
+ export declare function addTreeTools(tools: ToolEditor, ports: TreeToolPorts): void;
@@ -0,0 +1,167 @@
1
+ import { Schema } from "effect";
2
+ import { STATUSES } from "../kernel/protocol.js";
3
+ import { rethrow } from "../kernel/tool.js";
4
+ import { cleanup } from "./cleanup.js";
5
+ import { send } from "./send.js";
6
+ import { spawn } from "./spawn.js";
7
+ import { listChildren, status } from "./status.js";
8
+ import { stop } from "./stop.js";
9
+ import { tree } from "./subtree.js";
10
+ import { childrenText, cleanupText, sendText, spawnText, statusText, stopText, treeText } from "./text.js";
11
+ /**
12
+ * The tree's courier tools: their input schemas, descriptions, and what each returns. The descriptions are
13
+ * model-facing like the texts in `text.ts`, and snapshotted with them in `test/notices.test.ts`.
14
+ */
15
+ export const SpawnInput = Schema.Struct({
16
+ task: Schema.String.annotate({ description: "What the new session should do. It is told who started it and how to report back." }),
17
+ title: Schema.optional(Schema.String.annotate({ description: "Session title; defaults to the task's first line." })),
18
+ agent: Schema.optional(Schema.String.annotate({ description: "Agent to run the session with; defaults to the default agent." })),
19
+ isolate: Schema.optional(Schema.Boolean.annotate({ description: "Run the session in its own git worktree so parallel sessions don't share files. From a session that is itself " +
20
+ "isolated, the worktree starts from your current commit, not your uncommitted changes: commit first. The session " +
21
+ "commits on a branch named courier/<its session ID> and names it in its report; merge that branch yourself.",
22
+ })),
23
+ group: Schema.optional(Schema.String.annotate({
24
+ description: "A join group: start the sessions of one batch with the same name, and their reports are held and delivered " +
25
+ "to you in one message once every one of them has reported and your turn has ended, instead of one turn per " +
26
+ "report. A blocked report, a question, a permission request or a failed turn still reaches you at once. Letters, " +
27
+ "digits, dots, dashes and underscores, up to 60; the name is free again once the group has been delivered.",
28
+ })),
29
+ });
30
+ export const SendInput = Schema.Struct({
31
+ sessionID: Schema.String.annotate({ description: "The session to deliver to." }),
32
+ message: Schema.String.annotate({ description: "The message text." }),
33
+ status: Schema.optional(Schema.Literals(STATUSES).annotate({
34
+ description: "For a report to the session that started you: done, partial (some of the task is left), blocked (you need a " +
35
+ "decision from it; say what) or failed. A message without a status is progress, not your report.",
36
+ })),
37
+ artifacts: Schema.optional(Schema.Struct({
38
+ branch: Schema.optional(Schema.String.annotate({ description: "The branch your work is on." })),
39
+ commits: Schema.optional(Schema.Array(Schema.String).annotate({ description: "The commits you made, as short hashes or subjects." })),
40
+ files: Schema.optional(Schema.Array(Schema.String).annotate({ description: "The files you changed." })),
41
+ checks: Schema.optional(Schema.Array(Schema.Struct({
42
+ command: Schema.String.annotate({ description: "A command you ran, such as a test suite or a linter." }),
43
+ result: Schema.String.annotate({ description: "What it gave, in a few words." }),
44
+ })).annotate({ description: "The checks you ran and their results." })),
45
+ }).annotate({ description: "With a report: what you produced, listed in the message after your text in a fixed layout." })),
46
+ queue: Schema.optional(Schema.Boolean.annotate({ description: "Wait until the target's current turn ends instead of steering it now." })),
47
+ });
48
+ export const StatusInput = Schema.Struct({
49
+ sessionID: Schema.String.annotate({ description: "The session to look at." }),
50
+ });
51
+ export const ChildrenInput = Schema.Struct({
52
+ sessionID: Schema.optional(Schema.String.annotate({ description: "The session whose children to list; defaults to this one." })),
53
+ });
54
+ export const TreeInput = Schema.Struct({
55
+ sessionID: Schema.optional(Schema.String.annotate({ description: "The session whose subtree to list; defaults to this one." })),
56
+ });
57
+ export const StopInput = Schema.Struct({
58
+ sessionID: Schema.String.annotate({ description: "The session to stop, with every session under it. It must have been started, directly or through others, from this one." }),
59
+ cleanup: Schema.optional(Schema.Boolean.annotate({
60
+ description: "Also remove the worktrees of the stopped sessions that ran in one of their own, as courier_cleanup does: " +
61
+ "one with uncommitted changes or commits on no branch is kept and listed.",
62
+ })),
63
+ });
64
+ export const CleanupInput = Schema.Struct({
65
+ sessionID: Schema.String.annotate({ description: "The session you started to drop, with its worktree if it is isolated." }),
66
+ force: Schema.optional(Schema.Boolean.annotate({
67
+ description: "Remove it even with uncommitted changes or commits on no branch; that work is lost.",
68
+ })),
69
+ });
70
+ /** Adds the tree's courier tools, each a direct tool rather than a code-mode one. */
71
+ export function addTreeTools(tools, ports) {
72
+ tools.add({
73
+ name: "courier_spawn",
74
+ options: { codemode: false },
75
+ description: "Start a new OpenCode session on a task and return immediately. It runs on your model. The session reports " +
76
+ "back with courier_send, which wakes this session, and you are told if its turn fails or ends without a report " +
77
+ "instead, it waits for a permission or it asks a question. Give a batch of parallel sessions one group name to " +
78
+ "be woken once, with all their reports, instead of once per report. DO NOT poll it or call courier_status in a " +
79
+ "loop: once you have started the sessions you need, end your turn by replying without calling more tools; each " +
80
+ "report, or each group's reports, starts a new turn in which you carry on. For long tasks, also courier_later a " +
81
+ "check-in for yourself in case it never reports, and courier_cancel it when it does.",
82
+ input: SpawnInput,
83
+ execute: async (input, context) => {
84
+ const child = await spawn(ports.courier, context.sessionID, input).catch(rethrow("courier_spawn"));
85
+ return { content: spawnText(child), metadata: child };
86
+ },
87
+ });
88
+ tools.add({
89
+ name: "courier_send",
90
+ options: { codemode: false },
91
+ description: "Deliver a message to another OpenCode session. If that session is idle, OpenCode starts a new turn for it. " +
92
+ "Use it to report back to the session that started you, with a status (done, partial, blocked or failed) and " +
93
+ "the artifacts you produced, or to steer a session you started. The status is carried as an attribute of the " +
94
+ "message; without one, a message to the session that started you is progress, not your report. If you were " +
95
+ "started in a group, a report with status done, partial or failed is held and delivered with the group's other " +
96
+ "reports; the result says so, and nothing more is needed from you.",
97
+ input: SendInput,
98
+ execute: async (input, context) => {
99
+ const delivered = await send(ports.courier, context.sessionID, input).catch(rethrow("courier_send"));
100
+ return { content: sendText(input.sessionID, delivered), metadata: delivered };
101
+ },
102
+ });
103
+ tools.add({
104
+ name: "courier_status",
105
+ options: { codemode: false },
106
+ description: "Look once at a session's state and its last reply, e.g. on a scheduled check-in. Not for waiting: " +
107
+ "sessions you started report back on their own.",
108
+ input: StatusInput,
109
+ execute: async (input) => {
110
+ const result = await status(ports.courier, input).catch(rethrow("courier_status"));
111
+ return { content: statusText(result), metadata: result };
112
+ },
113
+ });
114
+ tools.add({
115
+ name: "courier_children",
116
+ options: { codemode: false },
117
+ description: "List the sessions this one started with courier_spawn, with each one's state and last reply, and for one " +
118
+ "started in a group, its group and whether its report is held, still out, or released, e.g. after a " +
119
+ "compaction or restart. Like courier_status, for a one-off look, not for waiting.",
120
+ input: ChildrenInput,
121
+ execute: async (input, context) => {
122
+ const listed = await listChildren(ports.courier, input.sessionID || context.sessionID).catch(rethrow("courier_children"));
123
+ return { content: childrenText(listed), metadata: { children: listed } };
124
+ },
125
+ });
126
+ tools.add({
127
+ name: "courier_tree",
128
+ options: { codemode: false },
129
+ description: "List the whole tree of sessions under this one (or under sessionID): every session started with courier_spawn " +
130
+ "from it, directly or through others, each with its depth, what courier_children shows, and whether it still " +
131
+ "owes a report, sent progress, or ended without one. For a one-off look, not for waiting; long trees are cut at maxTotal sessions.",
132
+ input: TreeInput,
133
+ execute: async (input, context) => {
134
+ const result = await tree(ports.courier, input.sessionID || context.sessionID).catch(rethrow("courier_tree"));
135
+ return { content: treeText(result), metadata: result };
136
+ },
137
+ });
138
+ tools.add({
139
+ name: "courier_stop",
140
+ options: { codemode: false },
141
+ description: "Stop a session you started, directly or through others, and every session under it: each running turn is " +
142
+ "interrupted, deepest first, and the messages scheduled for them with courier_later are cancelled. Use it when a " +
143
+ "subtree went wrong or is no longer needed. They do not report back, and you are not told of what you stopped; a " +
144
+ "session between you and the target is. A stopped session can be woken again with courier_send. With " +
145
+ "cleanup: true their worktrees are removed too, unless they hold work that is on no branch.",
146
+ input: StopInput,
147
+ execute: async (input, context) => {
148
+ const result = await stop(ports.stop, context.sessionID, input).catch(rethrow("courier_stop"));
149
+ return { content: stopText(result), metadata: result };
150
+ },
151
+ });
152
+ tools.add({
153
+ name: "courier_cleanup",
154
+ options: { codemode: false },
155
+ description: "Remove the git worktree of a session you started with isolate: true, once you have what you need from it, " +
156
+ "and drop it from courier_children. A worktree with uncommitted changes or commits on no branch is kept and " +
157
+ "the result lists them; commit or branch what you want, or pass force: true to discard it. A session without " +
158
+ "isolate has no worktree and is only dropped from courier_children. Either way, one in a join group that has not " +
159
+ "reported is dropped from it, so the group's other reports reach you without it; use this for a member you no " +
160
+ "longer need a report from. It never deletes the session itself, nor the sessions it started.",
161
+ input: CleanupInput,
162
+ execute: async (input, context) => {
163
+ const result = await cleanup(ports.cleanup, context.sessionID, input).catch(rethrow("courier_cleanup"));
164
+ return { content: cleanupText(result), metadata: result };
165
+ },
166
+ });
167
+ }
@@ -0,0 +1,53 @@
1
+ import type { Plugin } from "@opencode/plugin/tui";
2
+ import { type Row, type Stopped, type Summary, type Wait } from "./rpc.js";
3
+ type Context = Plugin.Context;
4
+ /** How long after the first of a burst of changes the entry asks the server again. */
5
+ export declare const SETTLE_MS = 250;
6
+ /** The panel's settle: each of its asks reads every row's messages, so a busy tree is asked at most once a second. */
7
+ export declare const PANEL_SETTLE_MS = 1000;
8
+ /** The name of the tree panel in the `session.panel` slot. */
9
+ export declare const PANEL = "courier-tree";
10
+ /** How often the panel redraws its running times and how long ago each last tool call was. */
11
+ export declare const TICK_MS = 1000;
12
+ /** The status entry's text, such as "courier 2 running · 1 waiting on you · group pair 1/2"; empty when there is nothing to show. */
13
+ export declare function summaryLine(summary: Summary): string;
14
+ /** A span of time, coarsest first: "45s", "3m 05s", "2h 07m". */
15
+ export declare function duration(ms: number): string;
16
+ /** How many of a session's children have reported, out of those it started, among the rows read. */
17
+ export declare function parts(rows: ReadonlyArray<Row>, parentID: string): {
18
+ reported: number;
19
+ started: number;
20
+ };
21
+ /** A bar of parts reported, `width` cells wide: "[████░░░░░░] 2/5 reported". */
22
+ export declare function bar({ reported, started }: {
23
+ reported: number;
24
+ started: number;
25
+ }, width?: number): string;
26
+ /** What a paused session waits on, such as "asks permission: shell echo hi". */
27
+ export declare function waitLine(wait: Wait): string;
28
+ /** What a row's session is doing, its report and its group standing: "idle · reported done · group pair held". */
29
+ export declare function statusLine(row: Row): string;
30
+ /** A leaf's activity: how long it has run, its last tool call and how long ago, and its cost. */
31
+ export declare function activityLine(row: Row, now: number): string;
32
+ /** The lines a row shows under its title: an orchestrator's bar, a paused session's requests, or else a live leaf's activity. */
33
+ export declare function detailLines(row: Row, rows: ReadonlyArray<Row>, now: number): string[];
34
+ /**
35
+ * Adds the tree's entry to the prompt's footer: what the sessions under the open session do, asked of the RPC after each
36
+ * change; nothing on the home screen. Clicked while some wait on the person, it calls `onWaiting` with the open session.
37
+ */
38
+ export declare function addTreeStatus(ctx: Context, onWaiting?: (sessionID: string) => void): () => void;
39
+ /** The toast after a stop: how many sessions were interrupted, and how many scheduled messages were cancelled. */
40
+ export declare function stoppedLine({ stopped, cancelled }: Stopped): string;
41
+ /** The usage the `/courier` command shows for an input it does not know. */
42
+ export declare const USAGE = "/courier tree opens the session tree; /courier stop [session] stops a session and those under it";
43
+ /**
44
+ * Adds the tree panel to the `session.panel` slot, opened by `/courier tree` or the command palette, and `/courier
45
+ * stop <session>`, which stops a session under the open one after the person confirms.
46
+ */
47
+ export declare function addTreePanel(ctx: Context): () => void;
48
+ /**
49
+ * Draws the person's attention when reports arrive: a join group's reports, released to the session that started them,
50
+ * and a child's report to its parent, with the `subagent_done` sound. Read from the courier's own delivery metadata.
51
+ */
52
+ export declare function addTreeAttention(ctx: Context): () => void;
53
+ export {};
package/dist/tui.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { Plugin } from "@opencode/plugin/tui";
2
+ declare const _default: Plugin.Definition;
3
+ export default _default;