opencode-courier 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +124 -20
  2. package/dist/bounded.d.ts +2 -4
  3. package/dist/bounded.js +2 -4
  4. package/dist/cleanup.d.ts +8 -3
  5. package/dist/cleanup.js +24 -12
  6. package/dist/courier.d.ts +116 -11
  7. package/dist/courier.js +239 -55
  8. package/dist/group.d.ts +86 -0
  9. package/dist/group.js +247 -0
  10. package/dist/hub.d.ts +52 -77
  11. package/dist/hub.js +68 -65
  12. package/dist/index.d.ts +4 -8
  13. package/dist/index.js +27 -20
  14. package/dist/json.d.ts +4 -0
  15. package/dist/json.js +11 -0
  16. package/dist/later.d.ts +6 -6
  17. package/dist/later.js +6 -5
  18. package/dist/limits.d.ts +65 -0
  19. package/dist/limits.js +109 -0
  20. package/dist/notices.d.ts +125 -15
  21. package/dist/notices.js +304 -25
  22. package/dist/question/answer.js +6 -9
  23. package/dist/question/lifecycle.d.ts +10 -19
  24. package/dist/question/lifecycle.js +10 -19
  25. package/dist/question/pure.d.ts +3 -4
  26. package/dist/question/pure.js +2 -3
  27. package/dist/question/relay.js +13 -27
  28. package/dist/question/runtime.d.ts +2 -3
  29. package/dist/question/runtime.js +2 -3
  30. package/dist/relay.d.ts +2 -4
  31. package/dist/relay.js +2 -4
  32. package/dist/report.d.ts +48 -0
  33. package/dist/report.js +88 -0
  34. package/dist/roster.d.ts +29 -17
  35. package/dist/roster.js +57 -26
  36. package/dist/skill.d.ts +13 -0
  37. package/dist/skill.js +9 -0
  38. package/dist/stop.d.ts +41 -0
  39. package/dist/stop.js +120 -0
  40. package/dist/tools.d.ts +20 -0
  41. package/dist/tools.js +81 -12
  42. package/dist/tree.d.ts +102 -0
  43. package/dist/tree.js +58 -0
  44. package/dist/version.d.ts +4 -10
  45. package/dist/version.js +4 -10
  46. package/dist/watch.d.ts +55 -18
  47. package/dist/watch.js +219 -34
  48. package/dist/webhook.d.ts +10 -15
  49. package/dist/webhook.js +11 -20
  50. package/package.json +1 -1
package/dist/notices.d.ts CHANGED
@@ -1,25 +1,88 @@
1
1
  /** Closes a tool result after which the caller most likely has nothing left to do. */
2
2
  export declare const END_TURN = "If nothing else is left to do now, end your turn by replying without calling more tools.";
3
- export declare function childBrief(parentID: string, task: string): string;
3
+ /** The limits on the tree of sessions courier_spawn builds, from the plugin's options. */
4
+ export interface Limits {
5
+ readonly maxDepth: number;
6
+ readonly maxChildren: number;
7
+ readonly maxTotal: number;
8
+ }
9
+ /** The statuses a report carries: the task is done, partly done, waits on the parent, or failed. */
10
+ export declare const STATUSES: readonly ["done", "partial", "blocked", "failed"];
11
+ export type Status = (typeof STATUSES)[number];
12
+ export declare const isStatus: (value: unknown) => value is Status;
13
+ /** The one line a session states its split decision in, so a transcript tells a decision from one never made. */
14
+ export declare const SPLIT_LINE = "split: no, because \u2026; considered: <the parts you weighed> or split: <n> parts: \u2026 (one phrase per part)";
15
+ /** The branch an isolated child commits its work on, and names in the `branch` of its report. */
16
+ export declare const childBranch: (sessionID: string) => string;
17
+ /** What a spawned session at `depth` is told; `depth` 1 is a child of a session nobody spawned. `branch` is for an isolated one. */
18
+ export declare function childBrief(parentID: string, task: string, depth: number, limits: Limits, branch?: string): string;
19
+ /** How every part the context hook adds begins, so a second copy of the plugin does not add its own. */
20
+ export declare const ROLE_PREFIX = "opencode-courier role:";
21
+ /**
22
+ * The system part naming a session's place in its tree: `depth` 0 for a session nobody spawned that
23
+ * has started one, `null` for one that has started none, which is pointed to the skill. The same on every request.
24
+ */
25
+ export declare function rolePart(depth: number | null, limits: Limits): string;
26
+ export declare const SKILL_ID = "courier-orchestrate";
27
+ /** Why a model loads the skill, listed with every session's available skills. */
28
+ export declare const SKILL_DESCRIPTION: string;
29
+ /** The skill's text; it names no limits, which the role part and the brief give for the session's own tree. */
30
+ export declare const SKILL_CONTENT: string;
31
+ export declare const depthRefusal: (depth: number, maxDepth: number) => string;
32
+ export declare const childrenRefusal: (running: number, maxChildren: number) => string;
33
+ export declare const totalRefusal: (running: number, maxTotal: number) => string;
4
34
  export declare function envelope(from: string, message: string, attributes?: Record<string, string>): string;
35
+ /** What a report lists besides its text; `checks` are the commands the session ran and what each gave. */
36
+ export interface Artifacts {
37
+ readonly branch?: string;
38
+ readonly commits?: ReadonlyArray<string>;
39
+ readonly files?: ReadonlyArray<string>;
40
+ readonly checks?: ReadonlyArray<{
41
+ readonly command: string;
42
+ readonly result: string;
43
+ }>;
44
+ }
45
+ /**
46
+ * The body of a report: its text, then the artifacts in a fixed layout, the same for every report,
47
+ * so a parent finds the branch, the commits, the files and the checks without reading prose.
48
+ */
49
+ export declare function reportBody(message: string, artifacts: Artifacts | undefined): string;
5
50
  export declare function spawnText(child: {
6
51
  readonly sessionID: string;
7
52
  readonly directory: string;
53
+ readonly group?: string;
8
54
  readonly rosterError?: string;
55
+ readonly groupError?: string;
56
+ readonly fromParent?: boolean;
57
+ readonly uncommitted?: boolean;
9
58
  }): string;
10
- export declare const sendText: (sessionID: string) => string;
59
+ /** A report held with its group: which group, and how many of its members have reported so far, when that could be read. */
60
+ export interface Held {
61
+ readonly group: string;
62
+ readonly reported?: number;
63
+ readonly members?: number;
64
+ }
65
+ /**
66
+ * What courier_send did; `report` says whether the message was noted as the sender's report to the session
67
+ * that started it, and `held` that it was held with the sender's group rather than delivered, with no `messageID`.
68
+ */
69
+ export interface Sent {
70
+ readonly messageID?: string;
71
+ readonly status?: Status;
72
+ readonly report?: boolean;
73
+ readonly held?: Held;
74
+ }
75
+ /** courier_send's result: for a message to the session that started the sender, whether it was its report. */
76
+ export declare function sendText(sessionID: string, sent?: Pick<Sent, "status" | "report" | "held">): string;
11
77
  /** courier_status's result: the session's state, as JSON. */
12
78
  export declare const statusText: (status: unknown) => string;
13
79
  export declare const childrenText: (listed: ReadonlyArray<unknown>) => string;
14
- /** What courier_cleanup did: removed the worktree, found it gone, or kept it and why. */
80
+ /** What courier_cleanup did: removed the worktree, found it gone, kept it and why, or forgot a child with none; `group`, one it dropped the child from. */
15
81
  export type CleanupResult = {
16
82
  readonly sessionID: string;
17
83
  readonly directory: string;
18
- readonly outcome: "removed";
19
- } | {
20
- readonly sessionID: string;
21
- readonly directory: string;
22
- readonly outcome: "gone";
84
+ readonly outcome: "removed" | "gone" | "forgotten";
85
+ readonly group?: string;
23
86
  } | {
24
87
  readonly sessionID: string;
25
88
  readonly directory: string;
@@ -29,6 +92,29 @@ export type CleanupResult = {
29
92
  readonly commits: readonly string[];
30
93
  };
31
94
  export declare function cleanupText(result: CleanupResult): string;
95
+ /** courier_tree's result: the sessions under a session, drawn depth first, as JSON. */
96
+ export declare function treeText(tree: {
97
+ readonly sessions: ReadonlyArray<unknown>;
98
+ readonly truncated?: number;
99
+ }): string;
100
+ /** What courier_stop did to each session, and to the messages and worktrees. */
101
+ export declare function stopText(result: {
102
+ readonly sessionID: string;
103
+ readonly stopped: ReadonlyArray<{
104
+ readonly sessionID: string;
105
+ readonly outcome: string;
106
+ readonly error?: string;
107
+ }>;
108
+ readonly cancelled: ReadonlyArray<string>;
109
+ readonly cleanup?: ReadonlyArray<CleanupResult | {
110
+ readonly sessionID: string;
111
+ readonly outcome: "failed";
112
+ readonly error: string;
113
+ }>;
114
+ readonly told?: string;
115
+ }): string;
116
+ /** What the parent of a stopped session is told when the stop was ordered by a session above both. */
117
+ export declare const stoppedNotice: (title: string, by: string) => string;
32
118
  /** Why a worktree in this state must be kept, or undefined when removing it loses nothing. */
33
119
  export declare function keepReason(state: {
34
120
  readonly changes: readonly string[];
@@ -40,9 +126,8 @@ export declare function keepReason(state: {
40
126
  */
41
127
  export type AnswerResult = PermissionAnswered | QuestionAnswered;
42
128
  /**
43
- * What answering a permission request did; `answered` is false when no location loaded in this
44
- * server held the request: it was answered, the session stopped waiting, or it waits in another
45
- * OpenCode server on the same data directory.
129
+ * What answering a permission request did; `answered` is false when no location loaded here held the
130
+ * request: answered already, no longer waited for, or waiting in another server on the same data dir.
46
131
  */
47
132
  export interface PermissionAnswered {
48
133
  readonly sessionID: string;
@@ -78,7 +163,33 @@ export interface ExecutionError {
78
163
  readonly message: string;
79
164
  readonly status?: number;
80
165
  }
81
- export declare function failureNotice(title: string, error: ExecutionError): string;
166
+ export declare function failureNotice(title: string, error: ExecutionError, group?: string): string;
167
+ /**
168
+ * What the parent is told when a session it started ends its turn without a report to it;
169
+ * `progressed` when it messaged the parent without a status since its last prompt.
170
+ */
171
+ export declare function silentNotice(title: string, lastText: string | undefined, progressed?: boolean, group?: string): string;
172
+ /** A report held with its group: its status, text and artifacts, as courier_send was given them, and when. */
173
+ export interface HeldReport {
174
+ readonly at: number;
175
+ readonly status: Status;
176
+ readonly message: string;
177
+ readonly artifacts?: Artifacts;
178
+ }
179
+ /** A member of a group that left it without a report, and why: its turn failed or was interrupted, it was deleted, or courier_cleanup dropped it. */
180
+ export interface LeftMember {
181
+ readonly sessionID: string;
182
+ readonly title: string;
183
+ readonly by: "failed" | "interrupted" | "deleted" | "dropped";
184
+ }
185
+ /**
186
+ * What the parent gets when a group it named is released: every report, with its status, text and artifacts in
187
+ * `reportBody`'s layout, and the members that left without one, in the order they joined.
188
+ */
189
+ export declare function groupNotice(group: string, reports: ReadonlyArray<HeldReport & {
190
+ readonly sessionID: string;
191
+ readonly title: string;
192
+ }>, left?: ReadonlyArray<LeftMember>): string;
82
193
  /** The answers to a permission request, as OpenCode's own prompt offers them. */
83
194
  export declare const REPLIES: readonly ["once", "always", "reject"];
84
195
  export type Reply = (typeof REPLIES)[number];
@@ -215,9 +326,8 @@ export declare function unlinkedNote(waiting: ReadonlyArray<Pick<Asked, "session
215
326
  /** The result of a linked call withdrawn because its question was settled without it. */
216
327
  export declare function withdrawnText(linked: Asked, how: Withdrawal): string;
217
328
  /**
218
- * What a linked call adds to the person's answers about passing them on: `passed` is what passing
219
- * them on gave ("result", "message", or undefined when it no longer waited), or the error as
220
- * `error: <message>`.
329
+ * What a linked call adds to the person's answers about passing them on: `passed` is "result",
330
+ * "message", undefined when it no longer waited, or the error as `error: <message>`.
221
331
  */
222
332
  export declare function passedNote(linked: Asked, passed: string | undefined): string;
223
333
  export declare function clipText(text: string, max?: number): string;
package/dist/notices.js CHANGED
@@ -1,53 +1,267 @@
1
1
  /**
2
- * Every model-facing text the plugin builds: tool results, the child brief, and the notices sessions
3
- * are woken with. With the tool descriptions in `tools.ts`, it is the plugin's real user interface;
4
- * `test/notices.test.ts` snapshots both, so a wording change shows up as a snapshot diff (and calls
5
- * for the real-model rerun CLAUDE.md asks for). Plain error messages, thrown where the check is and
6
- * passed on through `describeFailure`, are the exception: they stay with their checks, unsnapshotted.
7
- *
8
- * Pure functions on plain data; it imports nothing of the plugin's but `json.ts`, so any module
9
- * can use it without an import cycle.
2
+ * Every model-facing text the plugin builds; `test/notices.test.ts` snapshots them with the tool
3
+ * descriptions. Pure functions on plain data, importing nothing of the plugin's but `json.ts`.
10
4
  */
11
5
  import { num, obj, str } from "./json.js";
12
6
  // The envelope and the child brief.
13
7
  /** Closes a tool result after which the caller most likely has nothing left to do. */
14
8
  export const END_TURN = "If nothing else is left to do now, end your turn by replying without calling more tools.";
15
- export function childBrief(parentID, task) {
9
+ /** The statuses a report carries: the task is done, partly done, waits on the parent, or failed. */
10
+ export const STATUSES = ["done", "partial", "blocked", "failed"];
11
+ export const isStatus = (value) => typeof value === "string" && STATUSES.includes(value);
12
+ /** How a text names the statuses a report may carry. */
13
+ const STATUS_LIST = `${STATUSES.slice(0, -1).join(", ")} or ${STATUSES.at(-1)}`;
14
+ /** The one line a session states its split decision in, so a transcript tells a decision from one never made. */
15
+ export const SPLIT_LINE = "split: no, because …; considered: <the parts you weighed> or split: <n> parts: … (one phrase per part)";
16
+ /** The branch an isolated child commits its work on, and names in the `branch` of its report. */
17
+ export const childBranch = (sessionID) => `courier/${sessionID}`;
18
+ /** What a spawned session at `depth` is told; `depth` 1 is a child of a session nobody spawned. `branch` is for an isolated one. */
19
+ export function childBrief(parentID, task, depth, limits, branch) {
16
20
  return [
17
21
  `You were started by session ${parentID} through opencode-courier.`,
18
22
  "",
19
- `When you finish, or need a decision you cannot make yourself, call courier_send with sessionID "${parentID}" and a short report.`,
20
- "That message wakes the parent. It is the only way the parent hears from you, so do not end without sending it.",
23
+ `When you finish, send exactly one report: call courier_send with sessionID "${parentID}", a short account of what you did and ` +
24
+ "status done, partial (say what is left and why) or failed (say what went wrong). Add artifacts where there are any: " +
25
+ "branch, commits, files, checks (each command you ran and its result).",
26
+ "If you need a decision from the session that started you, which you cannot make yourself, send status blocked with a " +
27
+ "concrete ask, then end your turn and wait for its answer; never end quietly.",
28
+ "A report with a status wakes the parent and is the only way it hears from you, so do not end without sending one. " +
29
+ "A message without a status is progress, not your report: if you end your turn without a report, the parent is told so.",
21
30
  "If you need the person to decide something, use your question tool; it reaches them through the session that started you.",
22
31
  "",
32
+ ...(branch ? [...isolationRule(branch, depth < limits.maxDepth), ""] : []),
33
+ ...splitRule(parentID, depth, limits),
34
+ "",
23
35
  "Task:",
24
36
  task,
25
37
  ].join("\n");
26
38
  }
39
+ function isolationRule(branch, mayPass) {
40
+ return [
41
+ "You work in a git worktree of your own, on a detached HEAD. Commit your work on the branch " +
42
+ `${branch}: create it first (git switch -c ${branch}), and name it in the branch of your report's artifacts, so the ` +
43
+ "session that started you can merge it. Uncommitted work is not in your report.",
44
+ ...(mayPass
45
+ ? [
46
+ `If you split your task and start a session with isolate: true, commit first: its worktree starts from your current HEAD, ` +
47
+ "not from your uncommitted changes. Merge each branch it reports into yours before you report.",
48
+ ]
49
+ : []),
50
+ ];
51
+ }
52
+ function splitRule(parentID, depth, limits) {
53
+ if (depth >= limits.maxDepth)
54
+ return [`You are at depth ${depth}, the deepest the session tree goes, so you cannot start sessions: do the task yourself.`];
55
+ return [
56
+ `You are at depth ${depth} of a session tree that goes at most ${limits.maxDepth} deep. Split your task or do it yourself:`,
57
+ "- Split it when it has 2 or more independent parts, each substantial and touching separate files or areas: start one " +
58
+ `session per part with courier_spawn, at most ${limits.maxChildren} of yours running at once and ${limits.maxTotal} in the whole tree.`,
59
+ "- Do it yourself when it is small, sequential or tightly coupled. Never start exactly one session.",
60
+ `- Say which in one line of your reply: ${SPLIT_LINE}.`,
61
+ "- If you split, you orchestrate: end your turn while they work, check and integrate each part yourself when it " +
62
+ `reports (never hand that checking to another session), then send ${parentID} one combined report.`,
63
+ ];
64
+ }
65
+ /** How every part the context hook adds begins, so a second copy of the plugin does not add its own. */
66
+ export const ROLE_PREFIX = "opencode-courier role:";
67
+ /**
68
+ * The system part naming a session's place in its tree: `depth` 0 for a session nobody spawned that
69
+ * has started one, `null` for one that has started none, which is pointed to the skill. The same on every request.
70
+ */
71
+ export function rolePart(depth, limits) {
72
+ const { maxDepth, maxChildren, maxTotal } = limits;
73
+ const budget = `At most ${maxChildren} of the sessions you start run at once, and ${maxTotal} in the whole tree.`;
74
+ if (depth === null)
75
+ return (`${ROLE_PREFIX} root, no sessions started yet. Before a task that touches several files or areas, load the ` +
76
+ `${SKILL_ID} skill and decide whether to split it across sessions (doing it yourself is fine), and say ` +
77
+ `which in one line of your reply: ${SPLIT_LINE}`);
78
+ if (depth === 0)
79
+ return (`${ROLE_PREFIX} root orchestrator. You started sessions with courier_spawn, and they may start their own, ` +
80
+ `down to ${maxDepth} levels below you. ${budget} Check and integrate what each one reports yourself.`);
81
+ if (depth < maxDepth)
82
+ return (`${ROLE_PREFIX} sub-orchestrator, at depth ${depth} of at most ${maxDepth}. Split your task with courier_spawn or ` +
83
+ `do it yourself, as your brief says; sessions can be started ${count(maxDepth - depth, "level")} below you. ${budget}`);
84
+ return (`${ROLE_PREFIX} leaf, at depth ${depth}, the deepest the session tree goes. You cannot start sessions: do your ` +
85
+ "task yourself and report with courier_send to the session that started you.");
86
+ }
87
+ // The courier-orchestrate skill: the playbook a session loads with its skill tool.
88
+ export const SKILL_ID = "courier-orchestrate";
89
+ /** Why a model loads the skill, listed with every session's available skills. */
90
+ export const SKILL_DESCRIPTION = "Load at the start of any implementation task that may have independent parts, or when a session started you, to " +
91
+ "decide whether to split it across OpenCode sessions with courier_spawn and state the decision, and how: plan the " +
92
+ "parts, start them, end your turn, read the reports, verify the work, integrate it and report upward, and what to do " +
93
+ "when a part goes wrong.";
94
+ /** The skill's text; it names no limits, which the role part and the brief give for the session's own tree. */
95
+ export const SKILL_CONTENT = [
96
+ "How to split a task across OpenCode sessions with opencode-courier, and bring the results back up. Your brief, or the " +
97
+ "opencode-courier role line in your system prompt, names your depth in the session tree and its limits.",
98
+ "",
99
+ "1. Decide: split or do. Split when the task has 2 or more independent, substantial parts touching separate files or " +
100
+ "areas; do it yourself when it is small, sequential or tightly coupled. Never start exactly one session. " +
101
+ "An implementation task with one tightly coupled core usually still has independent parts: its e2e scenario, its " +
102
+ "documentation, sometimes a second test file, and a review of the core by a session with fresh context, which reads the " +
103
+ "committed diff against the task and the repository's rules and reports its findings (isolate: false, it edits nothing). " +
104
+ "Weigh each of them: do the core yourself first and commit it, so that the names and texts the other parts depend on are " +
105
+ "fixed, then, if 2 or more remain, start one session per remaining part, in one group. A part that is itself big and " +
106
+ "divisible is split again by the session that gets it; at the deepest level a session does its part itself. " +
107
+ `Say which in one line of your reply, before you start: ${SPLIT_LINE}, so that a reasoned decision to do it ` +
108
+ "yourself can be told from one never made.",
109
+ "2. Plan the parts so that no two edit the same files. Write each task so the session can do it without you: what to " +
110
+ "produce and where, how to check it, what to report. Say if it may split its part further.",
111
+ "3. Start them: one courier_spawn per part, all with the same group name, so you are woken once with every report; " +
112
+ "isolate: true for each part that edits files in parallel with another (commit your own work first if you are isolated " +
113
+ "yourself). For long work, courier_later a check-in for yourself, and courier_cancel it when the reports arrive.",
114
+ "4. End your turn: reply without calling more tools. Do not poll with courier_status, courier_children, courier_tree or " +
115
+ "sleep. The group's reports, a blocked report, a question, a permission request, a failed turn or a silent end each start " +
116
+ "a new turn for you.",
117
+ "5. Read each report by its status and its artifacts (branch, commits, files, checks). done: verify it. partial: what is " +
118
+ "left and why; have the session finish it with a courier_send, or do it yourself. blocked: make the decision it asks for " +
119
+ "and send it with courier_send; if only the person can make it, ask them with your question tool and pass the answer on. " +
120
+ "failed: read the error; message the session to try again, start a replacement, or carry on without it.",
121
+ "6. Verify first-hand: open the files, run the checks. Never hand the checking to another session: a review session's " +
122
+ "findings are a report to act on, and running the checks is still yours. Run checks through " +
123
+ "the project's scripts (bun run <script>, npm run <script>), not ./node_modules/.bin/<tool>, so that one permission " +
124
+ "rule covers them: your own permission prompts are relayed nowhere, and each one stalls the whole tree.",
125
+ "7. Integrate: merge each reported branch into your own checkout (git merge courier/<session>), resolve conflicts " +
126
+ "yourself, run the checks once more, then courier_cleanup the isolated sessions you are done with.",
127
+ "8. Report upward if a session started you: one courier_send to it, with one status for the whole (done only when every " +
128
+ "part is verified and integrated; partial, blocked or failed otherwise) and the artifacts. At the top, reply to the person.",
129
+ "",
130
+ "When a part goes wrong:",
131
+ "- A failed turn (failed=\"...\") or a silent end (ended=\"without-report\", quoting the session's last reply): message " +
132
+ "the session with courier_send to carry on, to report, or to try again; or start a replacement. Until it reports, it " +
133
+ "counts toward the limits.",
134
+ "- A refusal from courier_spawn naming maxDepth, maxChildren or maxTotal: do that part yourself, or end your turn and " +
135
+ "start it once a report has arrived. Do not retry it in a loop.",
136
+ "- Lost track, after a compaction or a restart: one courier_tree shows every session under you with its depth, its " +
137
+ "parent and whether it still owes a report; one look, not a loop.",
138
+ "- A subtree that went wrong or is no longer needed: courier_stop its top session; everything under it is stopped too, " +
139
+ "and nothing in it reports back. cleanup: true removes their worktrees.",
140
+ ].join("\n");
141
+ // courier_spawn's refusals, one per limit.
142
+ 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 ` +
143
+ "(maxDepth), so it cannot start sessions. Do the task yourself, and report with courier_send.";
144
+ /** How a refusal names sessions that count against a limit: those running, and those that have not reported yet. */
145
+ const live = (n) => (n === 1 ? "is running or has not reported yet" : "are running or have not reported yet");
146
+ export const childrenRefusal = (running, maxChildren) => `Not started: ${count(running, "session")} you started ${live(running)}, the most allowed at once ` +
147
+ `(maxChildren ${maxChildren}). Do this part yourself, or end your turn and start it once one of them has reported.`;
148
+ export const totalRefusal = (running, maxTotal) => `Not started: ${count(running, "session")} in this session tree ${live(running)}, the most allowed at once ` +
149
+ `(maxTotal ${maxTotal}). Do this part yourself, or end your turn and try again once a report has arrived.`;
27
150
  export function envelope(from, message, attributes = {}) {
28
151
  const extra = Object.entries(attributes)
29
152
  .map(([name, value]) => ` ${name}="${value}"`)
30
153
  .join("");
31
154
  return `<courier from="${from}"${extra}>\n${message}\n</courier>`;
32
155
  }
156
+ /** The strings of a list given as artifacts, whatever else the model put in it. */
157
+ const strings = (value) => (Array.isArray(value) ? value.flatMap((item) => (typeof item === "string" ? [item.trim()] : [])) : []).filter(Boolean);
158
+ /** A list's line in the artifacts block, or none when it is empty. */
159
+ const listed = (name, items) => (items.length ? [`- ${name}: ${items.join(", ")}`] : []);
160
+ /**
161
+ * The body of a report: its text, then the artifacts in a fixed layout, the same for every report,
162
+ * so a parent finds the branch, the commits, the files and the checks without reading prose.
163
+ */
164
+ export function reportBody(message, artifacts) {
165
+ const given = obj(artifacts);
166
+ const checks = (Array.isArray(given.checks) ? given.checks : []).flatMap((check) => {
167
+ const command = str(obj(check).command)?.trim();
168
+ return command ? [` - ${command}: ${str(obj(check).result)?.trim() || "(no result given)"}`] : [];
169
+ });
170
+ const lines = [
171
+ ...listed("branch", strings([given.branch])),
172
+ ...listed("commits", strings(given.commits)),
173
+ ...listed("files", strings(given.files)),
174
+ ...(checks.length ? ["- checks:", ...checks] : []),
175
+ ];
176
+ return lines.length ? [message, "", "Artifacts:", ...lines].join("\n") : message;
177
+ }
33
178
  // Tool results.
34
179
  export function spawnText(child) {
35
- const warning = child.rosterError ? ` It is not on your courier_children list: ${child.rosterError}` : "";
36
- return (`Started session ${child.sessionID} in ${child.directory}. It will report back with courier_send, which ` +
37
- "starts a new turn for you. Once you have started every session you need, end your turn: reply without " +
38
- `calling more tools. That does not drop the task; you carry on with it when the reports arrive.${warning}`);
180
+ const warning = (child.fromParent ? " Its worktree starts from the commit your own worktree is on." : "") +
181
+ (child.uncommitted
182
+ ? " Your worktree has uncommitted changes, which it does not see: commit them and tell it, or send them to it, if it needs them."
183
+ : "") +
184
+ (child.rosterError ? ` It is not on your courier_children list: ${child.rosterError}` : "") +
185
+ (child.groupError ? ` It is in no group, so its report comes on its own: ${child.groupError}` : "");
186
+ const reports = child.group
187
+ ? `Its report is held with group "${child.group}": once every session you started in that group has reported and your ` +
188
+ "turn has ended, you get their reports in one message, which starts a new turn for you; a blocked report, a question " +
189
+ "or a failed turn still reaches you at once."
190
+ : "It will report back with courier_send, which starts a new turn for you.";
191
+ return (`Started session ${child.sessionID} in ${child.directory}. ${reports} Once you have started every session you need, ` +
192
+ `end your turn: reply without calling more tools. That does not drop the task; you carry on with it when the reports arrive.${warning}`);
193
+ }
194
+ /** courier_send's result: for a message to the session that started the sender, whether it was its report. */
195
+ export function sendText(sessionID, sent = {}) {
196
+ const status = sent.status ? `, status ${sent.status}` : "";
197
+ if (sent.held) {
198
+ const { group, reported, members } = sent.held;
199
+ const counted = reported !== undefined && members !== undefined;
200
+ let when = `once ${sessionID}'s turn has ended, yours being the last.`;
201
+ if (!counted || reported < members) {
202
+ const have = counted ? ` (${reported} of ${members} have)` : "";
203
+ when = `once every member of the group has reported${have}. Nothing more is needed from you for it.`;
204
+ }
205
+ 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}`;
206
+ }
207
+ if (sent.report === true)
208
+ return `Delivered to ${sessionID} as your report${status}. ${END_TURN}`;
209
+ if (sent.report === false)
210
+ return `Delivered to ${sessionID}. Without a status it is progress, not your report: when you finish, send one with status ${STATUS_LIST}.`;
211
+ return `Delivered to ${sessionID}.`;
39
212
  }
40
- export const sendText = (sessionID) => `Delivered to ${sessionID}.`;
41
213
  /** courier_status's result: the session's state, as JSON. */
42
214
  export const statusText = (status) => JSON.stringify(status, null, 2);
43
215
  export const childrenText = (listed) => listed.length ? JSON.stringify(listed, null, 2) : "No sessions started with courier_spawn.";
44
216
  export function cleanupText(result) {
45
- if (result.outcome === "removed")
46
- return `Removed the worktree ${result.directory} of ${result.sessionID}.`;
47
- if (result.outcome === "gone")
48
- return `The worktree ${result.directory} of ${result.sessionID} was already gone; dropped it from courier_children.`;
49
- 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.`;
217
+ if (result.outcome === "kept")
218
+ 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.`;
219
+ const done = {
220
+ removed: `Removed the worktree ${result.directory} of ${result.sessionID}.`,
221
+ gone: `The worktree ${result.directory} of ${result.sessionID} was already gone; dropped it from courier_children.`,
222
+ forgotten: `${result.sessionID} ran in ${result.directory}, with no worktree of its own to remove; dropped it from courier_children.`,
223
+ }[result.outcome];
224
+ if (!result.group)
225
+ return done;
226
+ 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.`;
227
+ }
228
+ /** courier_tree's result: the sessions under a session, drawn depth first, as JSON. */
229
+ export function treeText(tree) {
230
+ if (!tree.sessions.length)
231
+ return "No sessions started with courier_spawn.";
232
+ 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.`;
233
+ return JSON.stringify(tree, null, 2) + note;
234
+ }
235
+ /** What courier_stop did to each session, and to the messages and worktrees. */
236
+ export function stopText(result) {
237
+ const interrupted = result.stopped.filter((one) => one.outcome === "interrupted").length;
238
+ const failed = result.stopped.filter((one) => one.outcome === "failed");
239
+ return [
240
+ `Stopped ${result.sessionID} and ${count(result.stopped.length - 1, "session")} under it, deepest first: ${interrupted} had a turn running and was interrupted, ` +
241
+ `${result.stopped.filter((one) => one.outcome === "idle" || one.outcome === "gone").length} did not run.`,
242
+ ...result.stopped.map((one) => `- ${one.sessionID}: ${one.outcome}${one.error ? " (" + one.error + ")" : ""}`),
243
+ ...(failed.length ? ["Those that failed may still run: try courier_stop again."] : []),
244
+ result.cancelled.length
245
+ ? `Cancelled ${count(result.cancelled.length, "scheduled message")}: ${result.cancelled.join(", ")}.`
246
+ : "No scheduled messages were pending for them.",
247
+ ...(result.told ? [`Told ${result.told}, which started ${result.sessionID}, that it was stopped.`] : []),
248
+ ...(result.cleanup ? worktreeLines(result.cleanup) : []),
249
+ ].join("\n");
50
250
  }
251
+ const FORCE_HINT = " Commit or branch what you want to keep, or call courier_cleanup again with force: true to discard it.";
252
+ /** courier_stop's lines on the worktrees it was asked to remove. */
253
+ function worktreeLines(cleanup) {
254
+ if (!cleanup.length)
255
+ return ["None of them ran in a worktree of its own."];
256
+ return cleanup.map((one) => one.outcome === "failed"
257
+ ? `Worktree of ${one.sessionID}: not removed (${one.error}).`
258
+ : cleanupText(one).replace(FORCE_HINT, " Kept; courier_cleanup with force: true discards it."));
259
+ }
260
+ /** What the parent of a stopped session is told when the stop was ordered by a session above both. */
261
+ export const stoppedNotice = (title, by) => [
262
+ `This session, "${title}", which you started with courier_spawn, was stopped by ${by}, a session above you, together with the sessions under it.`,
263
+ "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.",
264
+ ].join("\n");
51
265
  /** Why a worktree in this state must be kept, or undefined when removing it loses nothing. */
52
266
  export function keepReason(state) {
53
267
  const reasons = [
@@ -99,12 +313,78 @@ export function subscribeText(sessionID, topic, receiving) {
99
313
  return `Subscribed ${sessionID} to ${topic}.${note}`;
100
314
  }
101
315
  export const unsubscribeText = (sessionID, dropped) => dropped.length ? `Unsubscribed ${sessionID} from ${dropped.join(", ")}.` : `${sessionID} had no matching subscription.`;
102
- export function failureNotice(title, error) {
316
+ export function failureNotice(title, error, group) {
103
317
  const status = error.status === undefined ? "" : `, status ${error.status}`;
104
318
  return [
105
319
  `This session, "${title}", which you started with courier_spawn, failed: ${error.message} (${error.type}${status}).`,
106
320
  "Its turn ended without finishing, so it will not report back on its own.",
107
321
  "Message it with courier_send to have it try again, start a replacement, or carry on without it.",
322
+ ...(group
323
+ ? [
324
+ `It is a member of group "${group}", which no longer waits for it: its other members' reports reach you once they have ` +
325
+ "reported and your turn has ended, and if it reports after all before then, its report joins them.",
326
+ ]
327
+ : []),
328
+ ].join("\n");
329
+ }
330
+ /** Longest last reply a notice that a session ended its turn without reporting quotes. */
331
+ const MAX_REPLY = 2000;
332
+ /**
333
+ * What the parent is told when a session it started ends its turn without a report to it;
334
+ * `progressed` when it messaged the parent without a status since its last prompt.
335
+ */
336
+ export function silentNotice(title, lastText, progressed = false, group) {
337
+ const reply = lastText?.trim();
338
+ return [
339
+ `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.`,
340
+ ...(progressed
341
+ ? [`Its last message to you had no status, so it was progress, not its report: a report carries status ${STATUS_LIST}.`]
342
+ : []),
343
+ ...(reply ? ["Its last reply:", defuse(clipText(reply, MAX_REPLY))] : ["It ended without a reply."]),
344
+ "",
345
+ "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.",
346
+ "Until it reports, it counts toward your limits on the sessions you run at once.",
347
+ ...(group
348
+ ? [
349
+ `It is a member of group "${group}", whose other reports are held until it reports: have it report, as above, so the group ` +
350
+ "reaches you, or, if you no longer need its report, drop it with courier_cleanup, which releases the group without it.",
351
+ ]
352
+ : []),
353
+ ].join("\n");
354
+ }
355
+ /** How a release names each status among its reports: `2 done, 1 failed`. */
356
+ function statusCounts(reports) {
357
+ return STATUSES.flatMap((status) => {
358
+ const n = reports.filter((report) => report.status === status).length;
359
+ return n ? [`${n} ${status}`] : [];
360
+ }).join(", ");
361
+ }
362
+ const LEFT_HOW = {
363
+ failed: "its turn failed",
364
+ interrupted: "its turn was interrupted",
365
+ deleted: "it was deleted",
366
+ dropped: "you dropped it with courier_cleanup",
367
+ };
368
+ /**
369
+ * What the parent gets when a group it named is released: every report, with its status, text and artifacts in
370
+ * `reportBody`'s layout, and the members that left without one, in the order they joined.
371
+ */
372
+ export function groupNotice(group, reports, left = []) {
373
+ const total = reports.length + left.length;
374
+ const listed = reports.flatMap((report, index) => [
375
+ "",
376
+ `[${index + 1}/${reports.length}] ${report.sessionID} "${report.title}": ${report.status}`,
377
+ reportBody(report.message, report.artifacts),
378
+ ]);
379
+ const named = left.map((member) => `${member.sessionID} "${member.title}" (${LEFT_HOW[member.by]})`).join(", ");
380
+ const without = left.length ? ["", `Without a report: ${named}.`] : [];
381
+ const missing = left.length ? `, ${count(left.length, "member")} without a report, named at the end` : "";
382
+ return [
383
+ `Group "${group}" is complete: ${count(reports.length, "report")} of ${count(total, "member")} (${statusCounts(reports)})${missing}. Each report follows, with its status.`,
384
+ ...listed,
385
+ ...without,
386
+ "",
387
+ "Check and integrate each one yourself. The sessions that reported are idle; message one with courier_send if it has more to do.",
108
388
  ].join("\n");
109
389
  }
110
390
  // Permission requests.
@@ -302,9 +582,8 @@ export function withdrawnText(linked, how) {
302
582
  return `Session ${linked.sessionID} no longer waits on this question: it was ${withdrawnHow(linked, how)}. There is nothing to pass on; tell the person it is settled.`;
303
583
  }
304
584
  /**
305
- * What a linked call adds to the person's answers about passing them on: `passed` is what passing
306
- * them on gave ("result", "message", or undefined when it no longer waited), or the error as
307
- * `error: <message>`.
585
+ * What a linked call adds to the person's answers about passing them on: `passed` is "result",
586
+ * "message", undefined when it no longer waited, or the error as `error: <message>`.
308
587
  */
309
588
  export function passedNote(linked, passed) {
310
589
  if (passed === "result")
@@ -57,9 +57,8 @@ const holding = (id, ms, effect) => Effect.acquireUseRelease(Effect.sync(() => {
57
57
  export const deliver = (ports, asked, outcome) => Effect.suspend(() => {
58
58
  const id = asked.requestID;
59
59
  const wait = ports.timing.passingWaitMs;
60
- // One answer at a time, and once, per question in this process: storage cannot be claimed
61
- // atomically. A second answer waits for the first, and is passed on if the first was not.
62
- // While one is under way, nobody is told that the question was cut off, nor links to it.
60
+ // One answer at a time, and once, per question in this process, since storage cannot be claimed
61
+ // atomically: a second waits, going only if the first did not; meanwhile no cut-off notice or link.
63
62
  const since = ports.now();
64
63
  const left = () => wait - (ports.now() - since);
65
64
  const turn = holding(id, wait, Effect.gen(function* () {
@@ -73,9 +72,8 @@ export const deliver = (ports, asked, outcome) => Effect.suspend(() => {
73
72
  return undefined;
74
73
  const passing = passOn(ports, asked, outcome);
75
74
  shared.passing.set(id, passing);
76
- // Held while it is passed on. One that hangs, on a notice that never returns, gives way
77
- // after a while, so the question can be answered again; should it still go through, the
78
- // child is told twice.
75
+ // Held while it is passed on; one that hangs on a notice that never returns gives way after a
76
+ // while, so the question can be answered again, and the child may then be told twice.
79
77
  yield* within(passing, wait).pipe(Effect.ensuring(Effect.sync(() => {
80
78
  if (shared.passing.get(id) === passing)
81
79
  shared.passing.delete(id);
@@ -96,9 +94,8 @@ async function passOn(ports, asked, outcome) {
96
94
  known.link?.({ by: "top", outcome });
97
95
  return "result";
98
96
  }
99
- // Its call has just ended: whether it was cut off, and so stays registered, or was settled, and
100
- // so is dropped, is known once settle is through. The registry decides, not storage, which a
101
- // question whose record could not be written is missing from.
97
+ // Its call just ended: whether it was cut off and stays or was settled and is dropped is known once
98
+ // settle is through, from the registry, not storage, which lacks a question whose write failed.
102
99
  if (known) {
103
100
  await known.settling;
104
101
  if (shared.questions.get(id) !== known)