opencode-courier 0.14.0 → 0.15.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.
package/README.md CHANGED
@@ -85,7 +85,7 @@ name, without a version: OpenCode offers updates only for an entry without an ex
85
85
  <!-- compat-table:start rows=3: generated by script/compat-table.ts from the release tags -->
86
86
  | opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
87
87
  |---|---|
88
- | 0.14.0 | 2.2.6 |
88
+ | 0.14.0, 0.15.0 | 2.2.6 |
89
89
  | 0.13.7, 0.13.8 | 2.2.5 |
90
90
  | 0.13.6 | 2.2.4 |
91
91
  <!-- compat-table:end -->
package/dist/index.js CHANGED
@@ -115,7 +115,7 @@ export const courier = () => Plugin.define({
115
115
  .catch((error) => log(`courier rpc: not registered, the TUI part shows nothing and cannot stop: ${String(error)}`));
116
116
  // a host before 2.2.0 has no instructions entries (api-notes)
117
117
  const instructions = ctx.session.instructions;
118
- await ctx.session.hook("context", (event) => shapeContext({ storage: ctx.storage, limits, roles, instructions, hiddenFromLeaf: [RECALL_TOOL, NOTE_TOOL], log }, event));
118
+ await ctx.session.hook("context", (event) => shapeContext({ storage: ctx.storage, session: ctx.session, limits, roles, instructions, hiddenFromLeaf: [RECALL_TOOL, NOTE_TOOL], log }, event));
119
119
  const pruned = pruneExpired(ctx.storage, Date.now()).catch((error) => log(`courier roster prune: ${String(error)}`));
120
120
  const swept = sweepRecords(ctx.storage, Date.now()).catch((error) => log(`courier memory sweep: ${String(error)}`));
121
121
  const windowed = ports
@@ -2,9 +2,9 @@ export declare const END_TURN = "If nothing else is left to do now, end your tur
2
2
  export declare const STATUSES: readonly ["done", "partial", "blocked", "failed"];
3
3
  export type Status = (typeof STATUSES)[number];
4
4
  export declare const isStatus: (value: unknown) => value is Status;
5
- export declare const SPLIT_LINE = "split: no, because \u2026; considered: <the parts you weighed> or split: <n> parts: \u2026 (one phrase per part)";
6
- export declare const ONE_PART_LINE = "split: 1 part: <the task>, to keep my context";
7
- export declare const ROOT_SPLIT_LINE = "split: no, because \u2026; considered: <the parts you weighed> or split: <n> parts: \u2026 (one phrase per part) or split: 1 part: <the task>, to keep my context";
5
+ export declare const SPLIT_LINE = "`split: no, because \u2026; considered: <the parts you weighed>` or `split: <n> parts: \u2026` (one phrase per part)";
6
+ export declare const ONE_PART_LINE = "`split: 1 part: <the task>, to keep my context`";
7
+ export declare const ROOT_SPLIT_LINE = "`split: no, because \u2026; considered: <the parts you weighed>` or `split: <n> parts: \u2026` (one phrase per part) or `split: 1 part: <the task>, to keep my context`";
8
8
  export declare const SKILL_ID = "courier-orchestrate";
9
9
  export declare function envelope(from: string, message: string, attributes?: Record<string, string>): string;
10
10
  export declare function clipText(text: string, max?: number): string;
@@ -1,8 +1,8 @@
1
1
  export const END_TURN = "If nothing else is left to do now, end your turn by replying without calling more tools.";
2
2
  export const STATUSES = ["done", "partial", "blocked", "failed"];
3
3
  export const isStatus = (value) => typeof value === "string" && STATUSES.includes(value);
4
- export const SPLIT_LINE = "split: no, because …; considered: <the parts you weighed> or split: <n> parts: … (one phrase per part)";
5
- export const ONE_PART_LINE = "split: 1 part: <the task>, to keep my context";
4
+ export const SPLIT_LINE = "`split: no, because …; considered: <the parts you weighed>` or `split: <n> parts: …` (one phrase per part)";
5
+ export const ONE_PART_LINE = "`split: 1 part: <the task>, to keep my context`";
6
6
  export const ROOT_SPLIT_LINE = `${SPLIT_LINE} or ${ONE_PART_LINE}`;
7
7
  export const SKILL_ID = "courier-orchestrate";
8
8
  export function envelope(from, message, attributes = {}) {
@@ -1,4 +1,4 @@
1
1
  import { type Entry } from "../tree/index.js";
2
2
  import type { MemoryPorts } from "./ports.js";
3
3
  export declare function lessonsOf(ports: MemoryPorts): Promise<ReadonlyArray<Entry>>;
4
- export declare function relesson(ports: MemoryPorts, writerID: string, top: string): Promise<string[]>;
4
+ export declare function relesson(ports: MemoryPorts, top: string): Promise<string[]>;
@@ -9,7 +9,7 @@ export async function lessonsOf(ports) {
9
9
  const value = lessonsEntry(pinnedFirst(await memoryStore(ports.storage).ofProject(ports.projectID)));
10
10
  return value === undefined ? [] : [{ key: LESSONS_KEY, value }];
11
11
  }
12
- export async function relesson(ports, writerID, top) {
12
+ export async function relesson(ports, top) {
13
13
  const entries = ports.session.instructions?.entry;
14
14
  if (!entries)
15
15
  return [];
@@ -21,7 +21,7 @@ export async function relesson(ports, writerID, top) {
21
21
  if (!entry)
22
22
  return [];
23
23
  const children = ports.maxDepth > 1 ? await childrenOf(ports.storage, top) : [];
24
- sessions = [...new Set([top, ...children.map((child) => child.sessionID), writerID])];
24
+ sessions = [top, ...children.map((child) => child.sessionID)];
25
25
  }
26
26
  catch (error) {
27
27
  ports.log(`courier_note: the lessons entry could not be read again: ${String(error)}`);
@@ -6,9 +6,9 @@ export interface NoteInput {
6
6
  export type Refusal = {
7
7
  readonly kind: "off";
8
8
  } | {
9
- readonly kind: "leaf";
9
+ readonly kind: "below";
10
10
  readonly depth: number;
11
- readonly maxDepth: number;
11
+ readonly top: string;
12
12
  } | {
13
13
  readonly kind: "empty";
14
14
  } | {
@@ -6,22 +6,21 @@ export async function note(ports, callerID, input) {
6
6
  if (!ports.memory)
7
7
  return { kind: "off" };
8
8
  const chain = await lineage(ports.storage, callerID);
9
- const depth = chain.length;
10
- if (depth >= ports.maxDepth)
11
- return { kind: "leaf", depth, maxDepth: ports.maxDepth };
9
+ const top = chain.at(-1)?.parentID ?? callerID;
10
+ if (chain.length)
11
+ return { kind: "below", depth: chain.length, top };
12
12
  const text = oneLine(typeof input.text === "string" ? input.text : "");
13
13
  if (!text)
14
14
  return { kind: "empty" };
15
15
  if (text.length > LESSON_MAX)
16
16
  return { kind: "long", length: text.length };
17
- const top = chain.at(-1)?.parentID ?? callerID;
18
17
  const store = memoryStore(ports.storage);
19
18
  const { projectID } = ports;
20
19
  const record = await store.get(projectID, top);
21
20
  if (!record)
22
21
  return { kind: "unrecorded", top };
23
- const lesson = { text, at: ports.now(), ...(depth ? { depth } : {}) };
22
+ const lesson = { text, at: ports.now() };
24
23
  await store.put(projectID, top, { ...record, lesson });
25
- await relesson(ports, callerID, top);
24
+ await relesson(ports, top);
26
25
  return { kind: "noted", projectID, top, lesson, ...(record.lesson ? { replaced: record.lesson } : {}) };
27
26
  }
@@ -11,7 +11,7 @@ export declare function recallRecordText(record: StoredRecord): string;
11
11
  export declare const recallMissingText: (sessionID: string) => string;
12
12
  export declare function noteText(top: string, lesson: Lesson, replaced: Lesson | undefined): string;
13
13
  export declare const NOTE_OFF = "Not noted: the plugin's memory option is false, so no record of a session tree is kept in this project to write a lesson on.";
14
- export declare const noteLeafRefusal: (depth: number, maxDepth: number) => string;
14
+ export declare const noteBelowRefusal: (depth: number, top: string) => string;
15
15
  export declare const NOTE_EMPTY = "Not noted: text is empty. Write one line, what the next orchestrator in this project should know about running a tree like this one, with the job it comes from and the reason, or nothing.";
16
16
  export declare const noteLongRefusal: (length: number) => string;
17
17
  export declare const noteUnrecordedRefusal: (top: string) => string;
@@ -84,8 +84,8 @@ export function noteText(top, lesson, replaced) {
84
84
  ].join(" ");
85
85
  }
86
86
  export const NOTE_OFF = "Not noted: the plugin's memory option is false, so no record of a session tree is kept in this project to write a lesson on.";
87
- export const noteLeafRefusal = (depth, maxDepth) => `Not noted: this session is a leaf, at depth ${depth} of a session tree that goes at most ${maxDepth} deep (maxDepth). A lesson is ` +
88
- "written by the session that verified the tree, the top or one that started sessions; put what you learned in your report instead.";
87
+ export const noteBelowRefusal = (depth, top) => `Not noted: this session is at depth ${depth} of the session tree under ${top}, and only that top session writes the tree's ` +
88
+ "lesson, once the whole tree is verified. Put what you learned in your report instead: the top reads it and writes the tree's lesson.";
89
89
  const ONE_LINE = "what the next orchestrator in this project should know about running a tree like this one, with the job it comes from and the reason";
90
90
  export const NOTE_EMPTY = `Not noted: text is empty. Write one line, ${ONE_LINE}, or nothing.`;
91
91
  export const noteLongRefusal = (length) => `Not noted: the lesson is ${length} characters, at most ${LESSON_MAX}; write it shorter. Nothing was written, and the earlier lesson, ` +
@@ -4,7 +4,7 @@ import { rethrow } from "../kernel/tool.js";
4
4
  import { LESSON_MAX } from "./domain.js";
5
5
  import { note } from "./note.js";
6
6
  import { recall } from "./recall.js";
7
- import { NOTE_EMPTY, NOTE_OFF, NOTE_TOOL, noteLeafRefusal, noteLongRefusal, noteText, noteUnrecordedRefusal, RECALL_OFF, RECALL_TOOL, recallListText, recallMissingText, recallRecordText } from "./text.js";
7
+ import { NOTE_EMPTY, NOTE_OFF, NOTE_TOOL, noteBelowRefusal, noteLongRefusal, noteText, noteUnrecordedRefusal, RECALL_OFF, RECALL_TOOL, recallListText, recallMissingText, recallRecordText } from "./text.js";
8
8
  export const RecallInput = Schema.Struct({
9
9
  sessionID: Schema.optional(Schema.String.annotate({ description: "The top session of a listed record, for that record in full; without it, one line per record of this project." })),
10
10
  });
@@ -50,7 +50,7 @@ export function addMemoryTools(tools, ports) {
50
50
  "so that a lesson for one kind of job can be told from one that holds everywhere. One line, at most " +
51
51
  `${LESSON_MAX} characters; a longer one is refused, not clipped, so write it again shorter. Kept on your own tree's ` +
52
52
  "record and listed by courier_recall after its facts, dated; a second call replaces the first. From the top session " +
53
- "or one that started sessions, never a leaf. No code, no secrets, and not a rule that holds every time: that belongs " +
53
+ "only: a session below it puts what it learned in its report. No code, no secrets, and not a rule that holds every time: that belongs " +
54
54
  "in AGENTS.md or the skill, for the person to put there, so tell them instead.",
55
55
  input: NoteInput,
56
56
  execute: async (input, context) => {
@@ -63,8 +63,8 @@ export function addMemoryTools(tools, ports) {
63
63
  }
64
64
  case "off":
65
65
  throw refuse(NOTE_OFF);
66
- case "leaf":
67
- throw refuse(noteLeafRefusal(result.depth, result.maxDepth));
66
+ case "below":
67
+ throw refuse(noteBelowRefusal(result.depth, result.top));
68
68
  case "empty":
69
69
  throw refuse(NOTE_EMPTY);
70
70
  case "long":
@@ -42,7 +42,8 @@ export function addPersonTools(tools, ports) {
42
42
  name: "courier_answer",
43
43
  options: { codemode: false },
44
44
  description: "Pass on the answer to a permission request or a question that a session you started with courier_spawn (or " +
45
- "one started from it) waits on, after a notice from it named the request. For a permission request, reply is " +
45
+ "one started from it) waits on, after a notice from it named the request. Only the top session of a tree gets " +
46
+ "those notices and can answer. For a permission request, reply is " +
46
47
  "the answer you propose, once or reject: proposing is not deciding. With once, OpenCode asks the person you are " +
47
48
  "working with in your session, as its own permission prompt for what that session asked, under your permission " +
48
49
  "rules, and the call returns once they have answered; what they allow is passed on, and a refusal with a reason " +
@@ -18,8 +18,9 @@ export function addSchedulingTools(tools, later) {
18
18
  name: "courier_later",
19
19
  options: { codemode: false },
20
20
  description: "Schedule a message for a session (this one by default), delivered when due and waking it if idle. " +
21
- "Use it as a safety net when you start sessions: schedule one check-in, end your turn by replying without " +
22
- "calling more tools, and cancel it with courier_cancel if the child reports first. Survives server restarts; " +
21
+ "Use it as a safety net when you start sessions: schedule one check-in per group (per session " +
22
+ "when ungrouped), end your turn by replying without calling more tools, and cancel it with courier_cancel " +
23
+ "when the reports arrive, as a report does not cancel it. Survives server restarts; " +
23
24
  "may arrive up to ~15 seconds late.",
24
25
  input: LaterInput,
25
26
  execute: async (input, context) => {
@@ -34,7 +35,8 @@ export function addSchedulingTools(tools, later) {
34
35
  tools.add({
35
36
  name: "courier_cancel",
36
37
  options: { codemode: false },
37
- description: "Cancel a message scheduled with courier_later, e.g. because the child it was waiting for reported.",
38
+ description: "Cancel a message scheduled with courier_later, e.g. a check-in whose reports arrived. Nothing else cancels " +
39
+ "it but courier_stop of the session it is for.",
38
40
  input: CancelInput,
39
41
  execute: async (input) => {
40
42
  const cancelled = await cancel(later, input.id).catch(rethrow("courier_cancel"));
package/dist/skill.js CHANGED
@@ -1,110 +1,61 @@
1
1
  import { ONE_PART_LINE, SKILL_ID, SPLIT_LINE } from "./kernel/protocol.js";
2
- export const SKILL_DESCRIPTION = "How opencode-courier's session tools work. Load before you split a task with courier_spawn: when it has 2 or more " +
3
- "independent, substantial parts touching separate files or areas. Starting the parts, waiting without polling, " +
4
- "relaying questions and permissions, reading reports, merging and reporting upward.";
2
+ export const SKILL_DESCRIPTION = "How opencode-courier's session tools work. Load it when you decide whether to split a task across sessions with " +
3
+ "courier_spawn: one with 2 or more independent, substantial parts touching separate files or areas. Starting the " +
4
+ "parts, waiting without polling, permission requests and questions from below, verifying and merging.";
5
5
  export const SKILL_CONTENT = [
6
6
  "# How opencode-courier's session tools work",
7
7
  "",
8
- "Your brief, or the opencode-courier role line in your system prompt, names your depth in the session tree and its " +
9
- "limits. If courier_spawn is not among your tools, you cannot split: do the task yourself, and report upward as your " +
10
- "brief says if a session started you.",
8
+ "If courier_spawn is not among your tools, you cannot split: do the task yourself. Planning the parts, staging a " +
9
+ "tightly coupled core and reviewing the merge are not in this skill: if a skill for them is listed to you, load it too.",
11
10
  "",
12
- "This skill says how the tools behave. How to plan the parts, stage a tightly coupled core or review the merged result " +
13
- "is a playbook's: if a skill for that is listed to you, load it too.",
11
+ "## 1. Split or do",
14
12
  "",
15
- "## 1. Decide: split or do",
16
- "",
17
- "- At the root, first call courier_recall once, before you decide and never in a loop: it lists how earlier trees in " +
18
- "this project were split and how each part went. Below the root, read none: the records describe whole trees, which " +
19
- "the root weighed before it split.",
20
- "- Split when the task has 2 or more independent, substantial parts touching separate files or areas; do it yourself " +
21
- "when it is small, sequential or tightly coupled. Several whole jobs in one prompt, such as 4 tickets, are that many " +
22
- "parts: each session runs this skill for its job, so the tree below you repeats its shape once per job.",
23
- "- Only the root, the session nobody started, may start exactly one session for the whole task, and only when the " +
24
- "task is heavy (long reading, many edits, long runs) and its own context is worth keeping; it still verifies and " +
25
- "integrates that session's report itself. Every other session never starts exactly one session for the whole task: " +
26
- "it only adds a hop, and a chain of single hops uses up the tree's depth. One session for one stage is a part, not " +
27
- "the whole, as your brief says: a core you stage before the rest, when 2 or more parts follow it, or a review of " +
28
- "the parts once you have merged them.",
29
- `- Say which in one line of your reply, before you start: ${SPLIT_LINE}; the root has a third form, ${ONE_PART_LINE}.`,
13
+ "Split or do by the rule in your brief, the text above your task. At the root, the top session of the tree, which has no brief: split a task with 2 " +
14
+ "or more independent, substantial parts touching separate files or areas, do it yourself when it is small, " +
15
+ "sequential or tightly coupled, or hand a heavy one (long reading, many edits, long runs) whole to one session to " +
16
+ `keep your context. Say which in one line of your reply: ${SPLIT_LINE}, or, at the root only, ${ONE_PART_LINE}. Several whole ` +
17
+ "jobs in one prompt, such as 4 tickets, are that many parts. Below the root, never start one session for the whole " +
18
+ "task: it only adds a hop, and a chain of single hops uses up the tree's depth.",
30
19
  "",
31
20
  "## 2. Start the parts",
32
21
  "",
33
- "- One courier_spawn per part that runs at once, all with the same group name, so that you are woken once with every " +
34
- "report. No two parts edit the same files; isolate: true for each part that edits files in parallel with another. " +
35
- "When the parts do not all fit the tree's limits at once, start the parts that fit in one group, and the rest in a " +
36
- "group of their own once the first group has reported.",
22
+ "- One courier_spawn per part, the parts that run at once in one group. When they do not all fit the tree's limits " +
23
+ "at once, start waves, each its own group, or leave them ungrouped and start the next as each report frees a slot.",
24
+ "- No two parts edit the same files; isolate: true for each part that edits files in parallel with another.",
37
25
  "- Write each task so the session can do it without you, since it sees none of your conversation: what to produce " +
38
- "and where, the context it needs, how to check it so that one permission rule covers the checks (step 4), and what to report. Name " +
39
- "the skills it should load for its part, from those listed to you: it sees the same list, not your choice; one " +
40
- "permission rule for skill covers their loads, as one covers the checks. Say whether it may split its part further, " +
41
- "and when the part is a whole job that delivers itself (step 5), since an isolated session's brief otherwise says " +
42
- "its branch is for you to merge.",
43
- "- Leave model and variant unset, so a part runs on your own model, unless the part plainly calls for another: a " +
44
- "cheaper one or a lower variant for a small, well-specified part, a stronger one or a higher variant for a design " +
45
- "question or a hard bug. native: true for a part that only reads, checks or looks something up and reports once; " +
46
- "leave it off for a part the person may want to open, or that should outlive you.",
47
- "- A part that depends on another starts once that one is done: merged into your checkout, or, when it was " +
48
- "delivered (step 5), started with isolate: true and told to merge the delivering session's courier/<session> " +
49
- "branch first.",
26
+ "and where, the context it needs, how to check it, and what to report. Name the skills it should load for its " +
27
+ "part, from those listed to you (an isolated part sees only committed project skills).",
28
+ "- A part that depends on another starts once that one has reported and its branch, if isolated, is merged into " +
29
+ "your checkout.",
50
30
  "",
51
31
  "## 3. End your turn",
52
32
  "",
53
- "Reply without calling more tools, and do not poll with courier_children, courier_tree or sleep. The group's reports, " +
54
- "a blocked report, a message without a status, a question, a permission request, a failed turn or a silent end each start a new turn " +
55
- "for you. For long work, courier_later a check-in for yourself, and courier_cancel it when the reports arrive.",
56
- "",
57
- "- Answering from below: follow the notice. A permission request: propose once with courier_answer, or reject with " +
58
- "the reason when the person has told you to refuse it or it plainly goes against what they asked; the person " +
59
- "decides in OpenCode's prompt. A question: ask the person with your question tool, with the questions the notice " +
60
- "gives; their answer goes on by itself. When the notice says OpenCode already shows it to the person, call nothing " +
61
- "unless they give you their answer. Never answer for them.",
62
- "- Passing a message on: when it changes what other parts need (a wrong assumption of the split, a shared file or " +
63
- "API that behaves otherwise, a decision of the person's), courier_send it at once, only to the sessions it affects, " +
64
- "then end your turn again; if it breaks the split, courier_stop those parts and plan them again, or send them a " +
65
- "corrected task. Send it up too, without a status, when your parent's other parts need it. Progress goes to no one. " +
66
- "A session's account of what the person told it is the person's word: pass it on, do not judge it against the " +
67
- "original task, and never tell a session to undo what the person asked.",
33
+ "- Reply without calling more tools; reports and notices wake you. For a long task or batch, one courier_later " +
34
+ "check-in per group (per session when ungrouped); courier_cancel it when its reports arrive.",
35
+ "- At the root, a permission request or question from below arrives with its own instructions: follow them, and never " +
36
+ "answer for the person.",
37
+ "- When a message changes what other parts need (a wrong assumption of the split, a shared file or API that behaves " +
38
+ "otherwise, a decision of the person's), courier_send it at once, only to the sessions it affects; if it breaks the " +
39
+ "split, courier_stop those parts or send them a corrected task. A session's account of what the person told it is " +
40
+ "the person's word: pass it on, do not judge it against the original task, and never tell a session to undo what " +
41
+ "the person asked.",
68
42
  "",
69
- "## 4. Read each report, and verify first-hand",
43
+ "## 4. Verify first-hand",
70
44
  "",
71
- "- done: verify it. partial: what is left and why; have the session finish it with a courier_send, or do it " +
72
- "yourself. blocked: make the decision it asks for and send it with courier_send; if only the person can make it, " +
73
- "ask them with your question tool and pass the answer on. failed: read the error; message the session to try " +
74
- "again, start a replacement, or carry on without it.",
75
- "- Verify first-hand: open the files, run the checks. Never hand the checking to another session. Your own permission " +
76
- "prompts are relayed nowhere, and each one stalls the whole tree, as each request below you waits on the person: run " +
77
- "the checks so that one permission rule covers them, through the project's scripts rather than a tool's own path.",
45
+ "Open the files and run the project's checks yourself. A blocked report: make the decision it asks for and send it " +
46
+ "with courier_send; if only the person can make it, ask them with your question tool and pass the answer on. A " +
47
+ "partial or failed report: have it finish or retry with courier_send, do the rest yourself, or start a replacement.",
78
48
  "",
79
49
  "## 5. Integrate",
80
50
  "",
81
- "Merge each reported branch into your own checkout (git merge courier/<session>), resolve conflicts yourself, run the " +
82
- "checks once more, then courier_cleanup the isolated sessions you are done with. When a part is a whole job with a " +
83
- "deliverable of its own, its session delivers it and reports where, still naming its courier/<session> branch when " +
84
- "it is isolated; you verify each delivery and merge nothing of it into your checkout.",
85
- "",
86
- "## 6. Report upward",
87
- "",
88
- "- If a session started you: one courier_send to it, with one status for the whole (done only when every part is " +
89
- "verified and integrated, or verified and delivered; partial, blocked or failed otherwise) and the artifacts.",
90
- "- At the top, before you reply to the person: if the next tree in this project should know something about running " +
91
- "one like this (how it was split, what a part got wrong and what caught it, what a brief should have said, what the " +
92
- "person decided), one courier_note with that one line; none when there is nothing. Name the job it comes from and " +
93
- "the reason, so a lesson for one kind of job is not taken for every job; the tool refuses a line over its limit " +
94
- "rather than clipping it, so write it shorter. A rule that holds every time goes to AGENTS.md or a skill, through " +
95
- "the person, not into a note.",
51
+ "Merge each isolated part's reported branch into your own checkout (git merge courier/<session>), resolve conflicts " +
52
+ "yourself and run the checks once more. Then courier_cleanup each session you are done with, isolated or not.",
96
53
  "",
97
- "## When a part goes wrong",
54
+ "## 6. Done",
98
55
  "",
99
- '- A failed turn (failed="...") or a silent end (ended="without-report", quoting the session\'s last reply): message ' +
100
- "the session with courier_send to carry on, to report, or to try again; or start a replacement. A silent end counts " +
101
- "toward the limits until it reports; a failed turn does not, until you message it again.",
102
- "- A refusal from courier_spawn naming maxDepth, maxChildren or maxTotal: do that part yourself, or end your turn and " +
103
- "start it once a report has arrived. Do not retry it in a loop.",
104
- "- Lost track, after a compaction or a restart: one courier_tree shows every session under you with its depth, its " +
105
- "parent and whether it still owes a report; one look, not a loop.",
106
- "- A subtree that went wrong or is no longer needed: courier_stop its top session; everything under it is stopped " +
107
- "too, and nothing in it reports back. cleanup: true removes their worktrees.",
56
+ "The task is done only when every part is verified and integrated. At the root, before you reply to the person: one " +
57
+ "courier_note if the next tree in this project should know something about running one like this; none when there " +
58
+ "is nothing.",
108
59
  ].join("\n");
109
60
  // A skill:// path: the read tool serves it from the registry (api-notes)
110
61
  const SKILL_PATH = `skill://${SKILL_ID}/SKILL.md`;
@@ -5,12 +5,13 @@ const ROLES_MAX = 1000;
5
5
  const ROLE_LINE = new RegExp(`^${ROLE_PREFIX}`, "m");
6
6
  export async function shapeContext(ports, event) {
7
7
  try {
8
- const { depth, entry } = await roleOf(ports, event.sessionID);
9
- if (depth === null && !event.tools.courier_spawn)
10
- return;
11
- if (depth !== null && depth >= ports.limits.maxDepth)
8
+ const { depth, entry, subagent } = await roleOf(ports, event.sessionID);
9
+ const leaf = subagent || (depth !== null && depth >= ports.limits.maxDepth);
10
+ if (leaf)
12
11
  for (const tool of ["courier_spawn", ...(ports.hiddenFromLeaf ?? [])])
13
12
  delete event.tools[tool];
13
+ if (subagent || (depth === null && !event.tools.courier_spawn))
14
+ return;
14
15
  if (entry || event.system.some((part) => ROLE_LINE.test(part.text)))
15
16
  return;
16
17
  event.system.push({ type: "text", text: rolePart(depth, ports.limits) });
@@ -19,13 +20,24 @@ export async function shapeContext(ports, event) {
19
20
  ports.log(`courier: cannot tell the role of session ${event.sessionID}: ${String(error)}`);
20
21
  }
21
22
  }
22
- async function roleOf(ports, sessionID) {
23
+ async function roleOf(ports, sessionID, below = new Set()) {
23
24
  const known = ports.roles.get(sessionID);
24
25
  if (known !== undefined)
25
26
  return known;
26
27
  const children = childStore(ports.storage);
27
28
  const chain = await children.indexedLineage(sessionID);
28
29
  const depth = chain.length || ((await children.children(sessionID)).length ? 0 : null);
30
+ if (depth === null) {
31
+ const { parentID } = await ports.session.get({ sessionID });
32
+ if (parentID && !below.has(parentID)) {
33
+ const above = await roleOf(ports, parentID, new Set([...below, sessionID]));
34
+ if (above.depth === null && !above.subagent)
35
+ return { depth: null, entry: false };
36
+ const role = { depth: null, entry: false, subagent: true };
37
+ setBounded(ports.roles, sessionID, role, ROLES_MAX);
38
+ return role;
39
+ }
40
+ }
29
41
  const role = { depth, entry: depth !== null && (await hasEntry(ports, sessionID)) };
30
42
  setBounded(ports.roles, sessionID, role, ROLES_MAX);
31
43
  return role;
@@ -23,6 +23,11 @@ export interface InboxEnqueued {
23
23
  readonly inboxID: string;
24
24
  readonly item: {
25
25
  readonly type: string;
26
+ readonly payload?: {
27
+ readonly metadata?: {
28
+ readonly [key: string]: unknown;
29
+ };
30
+ };
26
31
  };
27
32
  };
28
33
  }
@@ -3,8 +3,9 @@ import { claim, SEEN_MAX, setBounded } from "../platform/bounded.js";
3
3
  import { endsIdle, retryDelay } from "./domain.js";
4
4
  import { forgetChild, forgetDeleted } from "./forget.js";
5
5
  import { hasCompleteGroup, leave, outIn } from "./group.js";
6
+ import { liveBelow } from "./limits.js";
6
7
  import { owedUnwaited } from "./nudge.js";
7
- import { stoppedSincePrompt } from "./report.js";
8
+ import { reportedSincePrompt, stoppedSincePrompt } from "./report.js";
8
9
  import { syncStanding } from "./standing.js";
9
10
  import { childStore, groupStore } from "./store.js";
10
11
  import { lastReply } from "./status.js";
@@ -26,9 +27,13 @@ export async function reportFailure(ports, seen, event) {
26
27
  try {
27
28
  if (quiet)
28
29
  return [];
30
+ const below = await liveBelow(ports, sessionID).then((live) => live.map((entry) => entry.title), (error) => {
31
+ ports.log(`courier watch: could not read the sessions under ${sessionID}: ${String(error)}`);
32
+ return [];
33
+ });
29
34
  await Promise.all(entries.map((entry, index) => ports.session.synthetic({
30
35
  sessionID: entry.parentID,
31
- text: envelope(sessionID, failureNotice(entry.title, error, out[index]?.group, at), { failed: error.type }),
36
+ text: envelope(sessionID, failureNotice(entry.title, error, out[index]?.group, at, below), { failed: error.type }),
32
37
  description: `Session ${sessionID} failed`,
33
38
  metadata: { source: "courier", from: sessionID, failed: true },
34
39
  delivery: "steer",
@@ -43,21 +48,29 @@ export async function reportFailure(ports, seen, event) {
43
48
  }
44
49
  // only inbox.enqueued carries the item's type (api-notes)
45
50
  const UNOWED = new Set(["user", "compaction", "move"]);
51
+ const GROUP = "group";
52
+ function kindOf(item) {
53
+ const metadata = item.payload?.metadata;
54
+ return item.type === "synthetic" && metadata?.source === "courier" && typeof metadata.group === "string" ? GROUP : item.type;
55
+ }
46
56
  export async function noteEnqueued(ports, inbox, event) {
47
57
  const { sessionID, inboxID, item } = event.data;
48
58
  if ((await childStore(ports.storage).indexedParent(sessionID)) === undefined)
49
59
  return false;
50
- setBounded(inbox, inboxID, item.type, SEEN_MAX);
60
+ setBounded(inbox, inboxID, kindOf(item), SEEN_MAX);
51
61
  return true;
52
62
  }
53
63
  export async function notePrompt(ports, event, inbox) {
54
64
  const { sessionID, inboxID } = event.data;
55
- if (inboxID !== undefined && UNOWED.has(inbox?.get(inboxID) ?? ""))
65
+ const kind = inboxID === undefined ? undefined : inbox?.get(inboxID);
66
+ if (UNOWED.has(kind ?? ""))
56
67
  return false;
57
68
  const children = childStore(ports.storage);
58
69
  const parentID = await children.indexedParent(sessionID);
59
70
  if (parentID === undefined)
60
71
  return false;
72
+ if (kind === GROUP && (await reportedSincePrompt(children, sessionID)))
73
+ return false;
61
74
  // the idle hook's reminder still publishes inbox.delivered (api-notes)
62
75
  if (inboxID !== undefined && (await children.nudgeOf(sessionID)).inboxID === inboxID)
63
76
  return false;
@@ -150,10 +163,10 @@ const TREE_TYPES = [
150
163
  "session.deleted",
151
164
  ];
152
165
  async function sealGroups(ports, event) {
153
- // a shutdown stamps no time.idle (api-notes)
154
- if (event.data.reason === "shutdown")
155
- return;
156
166
  try {
167
+ // a shutdown ends a turn only on a plain decline's aborted step (api-notes)
168
+ if (event.data.reason === "shutdown" && !endsIdle(await ports.session.context({ sessionID: event.data.sessionID }).catch(() => [])))
169
+ return;
157
170
  if (await hasCompleteGroup(groupStore(ports.storage), event.data.sessionID))
158
171
  ports.nudge();
159
172
  }
@@ -1,6 +1,6 @@
1
1
  import { isNotFound, millis } from "../platform/json.js";
2
2
  import { envelope } from "../kernel/protocol.js";
3
- import { complete, standingOf } from "./domain.js";
3
+ import { complete, endedAt, standingOf, turnEnd } from "./domain.js";
4
4
  import { syncStanding } from "./standing.js";
5
5
  import { groupStore } from "./store.js";
6
6
  import { groupNotice } from "./text.js";
@@ -109,8 +109,11 @@ async function sealed(ports, parentID, members) {
109
109
  const joined = Math.max(...members.map((member) => member.joinedAt));
110
110
  try {
111
111
  const { time } = await ports.session.get({ sessionID: parentID });
112
- // only a turn's end stamps time.idle, not a shutdown (api-notes)
113
- return millis(time.idle) >= joined;
112
+ if (millis(time.idle) >= joined)
113
+ return true;
114
+ // a plain decline ends the turn on an aborted step, no idle (api-notes)
115
+ const end = turnEnd(await ports.session.context({ sessionID: parentID }).catch(() => []));
116
+ return end !== undefined && (endedAt(end, time) ?? Number.NEGATIVE_INFINITY) >= joined;
114
117
  }
115
118
  catch (error) {
116
119
  if (isNotFound(error))
@@ -4,3 +4,4 @@ export declare function admit(ports: LimitPorts, parentID: string): Promise<{
4
4
  reservation: Reservation;
5
5
  release: () => void;
6
6
  }>;
7
+ export declare function liveBelow(ports: Pick<LimitPorts, "session" | "storage">, sessionID: string): Promise<import("./domain.js").RosterEntry[]>;
@@ -45,8 +45,15 @@ async function check(ports, parentID) {
45
45
  if (mine >= maxChildren)
46
46
  throw new Error(childrenRefusal(mine, maxChildren));
47
47
  if (counted.length + pending.length >= maxTotal)
48
- throw new Error(totalRefusal(counted.length + pending.length, maxTotal));
48
+ throw new Error(totalRefusal(counted.length + pending.length, maxTotal, mine));
49
49
  const reservation = { parentID, top };
50
50
  ports.gate.reserved.add(reservation);
51
51
  return { depth: chain.length + 1, reservation };
52
52
  }
53
+ export async function liveBelow(ports, sessionID) {
54
+ const entries = await childStore(ports.storage).all();
55
+ const parents = bySession(entries);
56
+ const below = [...parents.values()].filter((entry) => entry.sessionID !== sessionID && lineageBy(parents, entry.sessionID).some((above) => above.parentID === sessionID));
57
+ const counting = await Promise.all(below.map((entry) => live(ports, entry.sessionID)));
58
+ return below.filter((_, i) => counting[i]);
59
+ }
@@ -93,6 +93,7 @@ export interface Sent {
93
93
  export interface Role {
94
94
  readonly depth: number | null;
95
95
  readonly entry: boolean;
96
+ readonly subagent?: true;
96
97
  }
97
98
  export type Instructions = Partial<Pick<Context["session"], "instructions">>;
98
99
  export type Lister = Partial<Pick<Context["session"], "list">>;
@@ -211,7 +212,7 @@ export interface StopPorts {
211
212
  }
212
213
  export interface GroupPorts {
213
214
  readonly storage: Storage;
214
- readonly session: Pick<Context["session"], "synthetic" | "get"> & Instructions;
215
+ readonly session: Pick<Context["session"], "synthetic" | "get" | "context"> & Instructions;
215
216
  readonly now: () => number;
216
217
  readonly log: (message: string) => void;
217
218
  }
@@ -239,6 +240,7 @@ export interface ContextPorts {
239
240
  readonly storage: RosterStorage;
240
241
  readonly limits: Limits;
241
242
  readonly roles: Map<string, Role>;
243
+ readonly session: Pick<Context["session"], "get">;
242
244
  readonly instructions?: Context["session"]["instructions"];
243
245
  readonly hiddenFromLeaf?: ReadonlyArray<string>;
244
246
  readonly log: (message: string) => void;
@@ -2,6 +2,7 @@ import { type Status } from "../kernel/protocol.js";
2
2
  import type { ChildStore } from "./ports.js";
3
3
  type Notes = Pick<ChildStore, "notes">;
4
4
  export declare function stoppedSincePrompt(children: Notes, sessionID: string): Promise<boolean>;
5
+ export declare function reportedSincePrompt(children: Notes, sessionID: string): Promise<boolean>;
5
6
  export declare function lastStatus(children: Notes, sessionID: string): Promise<Status | undefined>;
6
7
  export declare function reportOf(children: Notes, sessionID: string): Promise<{
7
8
  resumesAt?: number | undefined;
@@ -4,6 +4,10 @@ export async function stoppedSincePrompt(children, sessionID) {
4
4
  const { stopped, prompt } = await children.notes(sessionID, ["stopped", "prompt"]);
5
5
  return stopped.at !== undefined && (prompt.at === undefined || stopped.at >= prompt.at);
6
6
  }
7
+ export async function reportedSincePrompt(children, sessionID) {
8
+ const { prompt, settled } = await children.notes(sessionID, ["prompt", "settled"]);
9
+ return prompt.at !== undefined && settled.by === "report" && settled.status !== "blocked" && since(settled.at, prompt.at);
10
+ }
7
11
  export async function lastStatus(children, sessionID) {
8
12
  const { status } = (await children.notes(sessionID, ["settled"])).settled;
9
13
  return isStatus(status) ? status : undefined;
@@ -10,6 +10,7 @@ export interface SpawnInput {
10
10
  readonly native?: boolean;
11
11
  }
12
12
  export declare function spawn(ports: CourierPorts, parentID: string, input: SpawnInput): Promise<{
13
+ belowTop?: boolean | undefined;
13
14
  native?: boolean | undefined;
14
15
  model?: string | undefined;
15
16
  uncommitted?: boolean | undefined;
@@ -60,17 +60,18 @@ async function start(ports, parentID, input, admitted, group) {
60
60
  ...(group && !rosterError && !groupError ? { group } : {}),
61
61
  ...(rosterError ? { rosterError } : {}),
62
62
  ...(groupError ? { groupError } : {}),
63
- ...(handedDown ? { fromParent: true } : {}),
63
+ ...(handedDown?.from ? { fromParent: true } : {}),
64
64
  ...(handedDown?.uncommitted ? { uncommitted: true } : {}),
65
65
  ...(model ? { model } : {}),
66
66
  ...(entry.native ? { native: true } : {}),
67
+ ...(admitted.depth > 1 ? { belowTop: true } : {}),
67
68
  };
68
69
  }
69
70
  async function create(ports, parentID, input) {
70
71
  const { model, parent } = await chooseModel(ports, parentID, input, input.agent);
71
72
  const handedDown = input.isolate ? await parentWork(ports, parentID) : undefined;
72
73
  const worktree = input.isolate
73
- ? (await ports.worktree.create({ projectID: ports.projectID, ...(handedDown ? { from: handedDown.directory, branch: handedDown.head } : {}) })).directory
74
+ ? (await ports.worktree.create({ projectID: ports.projectID, ...(handedDown?.from ? { from: handedDown.from.directory, branch: handedDown.from.head } : {}) })).directory
74
75
  : undefined;
75
76
  const base = worktree ? await ports.head(worktree) : undefined;
76
77
  const title = input.title ?? titleOf(input.task);
@@ -104,13 +105,12 @@ async function create(ports, parentID, input) {
104
105
  }
105
106
  async function parentWork(ports, parentID) {
106
107
  const parent = await childStore(ports.storage).indexedEntry(parentID);
107
- if (!parent?.isolated)
108
- return undefined;
109
- const head = await ports.head(parent.directory);
110
- if (!head)
111
- 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.`);
112
- const uncommitted = (await ports.dirty(parent.directory).catch(() => undefined)) === true;
113
- return { directory: parent.directory, head, uncommitted };
108
+ const directory = parent?.directory ?? ports.directory;
109
+ const head = parent?.isolated ? await ports.head(directory) : undefined;
110
+ if (parent?.isolated && !head)
111
+ throw new Error(`the HEAD of your worktree ${directory} could not be read, so an isolated session cannot start from it; nothing was started. Start it without isolate, or fix the worktree.`);
112
+ const uncommitted = (await ports.dirty(directory).catch(() => undefined)) === true;
113
+ return { ...(head ? { from: { directory, head } } : {}), uncommitted };
114
114
  }
115
115
  async function enrol(ports, entry, group) {
116
116
  const rosterError = await childStore(ports.storage).record(entry).then(() => undefined, (error) => describeFailure("roster", error).message);
@@ -28,7 +28,7 @@ export interface StandingRow {
28
28
  export declare function standingEntry(rows: ReadonlyArray<StandingRow>): string;
29
29
  export declare const depthRefusal: (depth: number, maxDepth: number) => string;
30
30
  export declare const childrenRefusal: (running: number, maxChildren: number) => string;
31
- export declare const totalRefusal: (running: number, maxTotal: number) => string;
31
+ export declare const totalRefusal: (running: number, maxTotal: number, yours: number) => string;
32
32
  export declare const modelFormatRefusal: (given: string) => string;
33
33
  export declare function unknownModelRefusal(model: ModelRef, known: ReadonlyArray<Pick<ModelRef, "providerID" | "id">>, named: boolean): string;
34
34
  export declare function unknownVariantRefusal(model: ModelRef, variants: ReadonlyArray<string>): string;
@@ -44,6 +44,7 @@ export declare function spawnText(child: {
44
44
  readonly fromParent?: boolean;
45
45
  readonly uncommitted?: boolean;
46
46
  readonly model?: string;
47
+ readonly belowTop?: boolean;
47
48
  }): string;
48
49
  export declare function sendText(sessionID: string, sent?: Pick<Sent, "status" | "report" | "held" | "dropped">): string;
49
50
  export declare const childrenText: (listed: ReadonlyArray<unknown>) => string;
@@ -73,7 +74,7 @@ export declare function keepReason(state: {
73
74
  readonly changes: readonly string[];
74
75
  readonly commits: readonly string[];
75
76
  }): string | undefined;
76
- export declare function failureNotice(title: string, error: ExecutionError, group?: string, at?: number): string;
77
+ export declare function failureNotice(title: string, error: ExecutionError, group?: string, at?: number, below?: ReadonlyArray<string>): string;
77
78
  export declare function reportReminder(parentID: string): string;
78
79
  export declare function silentNotice(title: string, lastText: string | undefined, progressed?: boolean, group?: string): string;
79
80
  export declare function groupNotice(group: string, reports: ReadonlyArray<HeldReport & {
package/dist/tree/text.js CHANGED
@@ -16,10 +16,12 @@ export function childBrief(parentID, task, depth, limits, branch) {
16
16
  "A message without a status is progress, not your report: if you end your turn without a report, the parent is told so.",
17
17
  "If you find something the other parts of the job need (an assumption of the split that is wrong, a shared file or API " +
18
18
  "that behaves otherwise), send it to the parent at once with courier_send without a status, then carry on: it passes " +
19
- "it on to the sessions it affects. Progress alone is not worth a message.",
19
+ "it on to the sessions it affects. Do the same with one a session you started sends you, when it reaches past your own " +
20
+ "part. Progress alone is not worth a message.",
20
21
  "A message the person writes in your session, outside a <courier> envelope, is for the whole job: before you act on it, " +
21
- "send it to the parent at once with courier_send without a status, quoting it as the person's, then carry on.",
22
- "If you need the person to decide something, use your question tool; it reaches them through the session that started you.",
22
+ "send it to the parent at once with courier_send without a status, quoting it as the person's, then carry on. The " +
23
+ "person's words a session you started passes you are for the whole job too: send them on to the parent the same way.",
24
+ "If you need the person to decide something, use your question tool; it reaches them through the top session of the tree.",
23
25
  "",
24
26
  ...(branch ? [...isolationRule(branch, depth < limits.maxDepth), ""] : []),
25
27
  ...splitRule(parentID, depth, limits),
@@ -68,12 +70,13 @@ export function rolePart(depth, limits) {
68
70
  `${SKILL_ID} skill, call courier_recall once for how earlier trees in this project were split and how they went, ` +
69
71
  `and decide whether to split it across sessions (doing it yourself is fine; so is handing a heavy task, with long ` +
70
72
  `reading, many edits or long runs, whole to one session to keep your context, but never a small one), and say ` +
71
- `which in one line of your reply: ${ROOT_SPLIT_LINE}`);
73
+ `which in one line of your reply: ${ROOT_SPLIT_LINE}.`);
72
74
  if (depth === 0)
73
75
  return (`${ROLE_PREFIX} root orchestrator. You started sessions with courier_spawn, and they may start their own, ` +
74
- `down to ${maxDepth} levels below you. ${budget} Check and integrate what each one reports yourself.`);
76
+ `down to ${maxDepth} levels below you. ${budget} Check and integrate what each one reports yourself. ` +
77
+ `Load the ${SKILL_ID} skill if it is not in your context.`);
75
78
  if (depth < maxDepth)
76
- return (`${ROLE_PREFIX} sub-orchestrator, at depth ${depth} of at most ${maxDepth}. Before you split, load the ${SKILL_ID} ` +
79
+ return (`${ROLE_PREFIX} sub-orchestrator, at depth ${depth} of at most ${maxDepth}. Before you decide whether to split, load the ${SKILL_ID} ` +
77
80
  `skill. Split your task with courier_spawn or do it yourself, as your brief says; sessions can be started ${count(maxDepth - depth, "level")} below you. ${budget}`);
78
81
  return (`${ROLE_PREFIX} leaf, at depth ${depth}, the deepest the session tree goes. You cannot start sessions: do your ` +
79
82
  "task yourself and report with courier_send to the session that started you.");
@@ -134,9 +137,11 @@ export const depthRefusal = (depth, maxDepth) => `Not started: this session is a
134
137
  "(maxDepth), so it cannot start sessions. Do the task yourself, and report with courier_send.";
135
138
  const live = (n) => (n === 1 ? "is running or has not reported yet" : "are running or have not reported yet");
136
139
  export const childrenRefusal = (running, maxChildren) => `Not started: ${count(running, "session")} you started ${live(running)}, the most allowed at once ` +
137
- `(maxChildren ${maxChildren}). Do this part yourself, or end your turn and start it once one of them has reported.`;
138
- export const totalRefusal = (running, maxTotal) => `Not started: ${count(running, "session")} in this session tree ${live(running)}, the most allowed at once ` +
139
- `(maxTotal ${maxTotal}). Do this part yourself, or end your turn and try again once a report has arrived.`;
140
+ `(maxChildren ${maxChildren}). Do this part yourself, or end your turn and start it once one of them has reported, not in a loop.`;
141
+ export const totalRefusal = (running, maxTotal, yours) => `Not started: ${count(running, "session")} in this session tree ${live(running)}, the most allowed at once (maxTotal ${maxTotal}). ` +
142
+ (yours === 0
143
+ ? "Do this part yourself: none of the sessions you started is running, so no report will wake you."
144
+ : "Do this part yourself, or end your turn and try again once a report has arrived, not in a loop.");
140
145
  const LISTED_MAX = 12;
141
146
  const some = (items) => (items.length > LISTED_MAX ? `${items.slice(0, LISTED_MAX).join(", ")} and ${items.length - LISTED_MAX} more` : items.join(", "));
142
147
  export const modelFormatRefusal = (given) => `Not started: model must be provider/model, as OpenCode names them (opencode/gpt-5-nano, anthropic/claude-sonnet-4-5), not ${JSON.stringify(given)}. ` +
@@ -184,15 +189,18 @@ export const nativeIsolateRefusal = () => "Not started: native: true and isolate
184
189
  export function spawnText(child) {
185
190
  const warning = (child.fromParent ? " Its worktree starts from the commit your own worktree is on." : "") +
186
191
  (child.uncommitted
187
- ? " Your worktree has uncommitted changes, which it does not see: commit them and tell it, or send them to it, if it needs them."
192
+ ? " Your working tree has uncommitted changes, which it does not see: commit them and tell it, or send them to it, if it needs them."
188
193
  : "") +
189
194
  (child.rosterError ? ` It is not on your courier_children list: ${child.rosterError}` : "") +
190
195
  (child.groupError ? ` It is in no group, so its report comes on its own: ${child.groupError}` : "");
196
+ const asks = child.belowTop
197
+ ? " Its permission requests and questions go to the top session of the tree, not to you, so a child slow to report may be waiting on the person."
198
+ : "";
191
199
  const reports = child.group
192
200
  ? `Its report is held with group "${child.group}": once every session you started in that group has reported and your ` +
193
- "turn has ended, you get their reports in one message, which starts a new turn for you; a blocked report, a question " +
194
- "or a failed turn still reaches you at once."
195
- : "It will report back with courier_send, which starts a new turn for you.";
201
+ `turn has ended, you get their reports in one message, which starts a new turn for you; a blocked report${child.belowTop ? "" : ", a question"} ` +
202
+ `or a failed turn still reaches you at once.${asks}`
203
+ : `It will report back with courier_send, which starts a new turn for you.${asks}`;
196
204
  const on = child.model ? `, on ${child.model}` : "";
197
205
  return (`Started session ${child.sessionID} in ${child.directory}${on}. ${reports} Once you have started every session you need, ` +
198
206
  `end your turn: reply without calling more tools. That does not drop the task; you carry on with it when the reports arrive.${warning}`);
@@ -240,14 +248,18 @@ export function treeText(tree) {
240
248
  const note = `\n${older} not listed: at most maxTotal are, the live ones first, then the newest; courier_tree on a listed session lists what is under it.`;
241
249
  return JSON.stringify(tree, null, 2) + note;
242
250
  }
251
+ const IDLE_STOPPED = "Stopping an idle session does not settle it: one that owed a report still owes it, stays in its join group and " +
252
+ "counts toward maxChildren and maxTotal until the session that started it drops it with courier_cleanup.";
243
253
  export function stopText(result) {
244
254
  const interrupted = result.stopped.filter((one) => one.outcome === "interrupted").length;
245
255
  const failed = result.stopped.filter((one) => one.outcome === "failed");
256
+ const idle = result.stopped.some((one) => one.outcome === "idle" && !one.subagent);
246
257
  return [
247
258
  `Stopped ${result.sessionID} and ${count(result.stopped.length - 1, "session")} under it, deepest first: ${interrupted} had a turn running and was interrupted, ` +
248
259
  `${result.stopped.filter((one) => one.outcome === "idle" || one.outcome === "gone").length} did not run.`,
249
260
  ...result.stopped.map((one) => `- ${one.sessionID}${one.subagent ? " (subagent)" : ""}: ${one.outcome}${one.error ? " (" + one.error + ")" : ""}`),
250
261
  ...(failed.length ? ["Those that failed may still run: try courier_stop again."] : []),
262
+ ...(idle ? [IDLE_STOPPED] : []),
251
263
  result.cancelled.length
252
264
  ? `Cancelled ${count(result.cancelled.length, "scheduled message")}: ${result.cancelled.join(", ")}.`
253
265
  : "No scheduled messages were pending for them.",
@@ -297,17 +309,24 @@ function waitLine(delay, at) {
297
309
  const until = at === undefined ? "" : `, until about ${clock(at + delay, at)}`;
298
310
  return `The provider asked to wait about ${wait(delay)} before trying again${until}.`;
299
311
  }
300
- export function failureNotice(title, error, group, at) {
312
+ function belowLine(below) {
313
+ const titles = below.map((title) => `"${title}"`).join(", ");
314
+ return (`It has ${count(below.length, "session")} of its own still running (${titles}), whose reports will wake it, and it still counts ` +
315
+ "toward your limits: message it rather than replace it, or stop it with courier_stop first, which stops the sessions under it too.");
316
+ }
317
+ export function failureNotice(title, error, group, at, below = []) {
301
318
  const status = error.status === undefined ? "" : `, status ${error.status}`;
302
319
  const delay = retryDelay(error);
320
+ const replace = below.length ? "" : " start a replacement,";
303
321
  return [
304
322
  `This session, "${title}", which you started with courier_spawn, failed: ${error.message} (${error.type}${status}).`,
305
323
  ...(delay === undefined ? [] : [waitLine(delay, at)]),
306
- "Its turn ended without finishing, so it will not report back on its own.",
324
+ below.length ? "Its turn ended without finishing." : "Its turn ended without finishing, so it will not report back on its own.",
307
325
  delay === undefined
308
- ? "Message it with courier_send to have it try again, start a replacement, or carry on without it."
309
- : "Do not message it with courier_send before then, as the turn would fail again. Message it after, start a replacement, or carry on without it; " +
326
+ ? `Message it with courier_send to have it try again,${replace} or carry on without it.`
327
+ : `Do not message it with courier_send before then, as the turn would fail again. Message it after,${replace} or carry on without it; ` +
310
328
  "courier_later can remind you when the wait is over.",
329
+ ...(below.length ? [belowLine(below)] : []),
311
330
  ...(group
312
331
  ? [
313
332
  `It is a member of group "${group}", which no longer waits for it: its other members' reports reach you once they have ` +
@@ -334,7 +353,9 @@ export function silentNotice(title, lastText, progressed = false, group) {
334
353
  : []),
335
354
  ...(reply ? ["Its last reply:", defuse(clipText(reply, QUOTED_REPLY_MAX))] : ["It ended without a reply."]),
336
355
  "",
337
- "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.",
356
+ reply
357
+ ? "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."
358
+ : "Decide what it needs: message it with courier_send to have it carry on; or start a replacement.",
338
359
  "Until it reports, it counts toward your limits on the sessions you run at once.",
339
360
  ...(group
340
361
  ? [
@@ -21,21 +21,23 @@ export const SpawnInput = Schema.Struct({
21
21
  "it unset for the chosen model's own; with a model of your own choosing, that is the model's default. Refused when " +
22
22
  "the model has no such variant.",
23
23
  })),
24
- 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 " +
25
- "isolated, the worktree starts from your current commit, not your uncommitted changes: commit first. The session " +
24
+ isolate: Schema.optional(Schema.Boolean.annotate({ description: "Run the session in its own git worktree so parallel sessions don't share files. Commit first: its worktree " +
25
+ "starts from your last commit and sees only committed files and project skills, not your uncommitted changes. The session " +
26
26
  "commits on a branch named courier/<its session ID> and names it in its report; merge that branch yourself.",
27
27
  })),
28
28
  native: Schema.optional(Schema.Boolean.annotate({
29
29
  description: "true starts the session as your OpenCode subagent: it leaves OpenCode's session lists for your subagent tab, the " +
30
30
  "person answers its permission requests and questions in the view of the nearest session above it that is not a " +
31
- "subagent, usually the top one, and deleting your session deletes it. false starts a standalone session, whatever the " +
31
+ "subagent, usually the top one (only OpenCode's TUI or paired app shows them; under opencode run without --auto " +
32
+ "or a bare API client they are rejected or wait unseen), and deleting your session deletes it. false starts a standalone session, whatever the " +
32
33
  "plugin's nativeChildren option says; unset, that option decides. Refused with isolate: true, as a subagent runs in " +
33
34
  "its parent's directory.",
34
35
  })),
35
36
  group: Schema.optional(Schema.String.annotate({
36
37
  description: "A join group: start the sessions of one batch with the same name, and their reports are held and delivered " +
37
38
  "to you in one message once every one of them has reported and your turn has ended, instead of one turn per " +
38
- "report. A blocked report, a question, a permission request or a failed turn still reaches you at once. Letters, " +
39
+ "report. A blocked report or a failed turn still reaches you at once, and so do a question and a permission request " +
40
+ "(at the top of the tree; below it they go to the top). Letters, " +
39
41
  "digits, dots, dashes and underscores, up to 60; the name is free again once the group has been delivered.",
40
42
  })),
41
43
  });
@@ -83,11 +85,13 @@ export function addTreeTools(tools, ports) {
83
85
  description: "Start a new OpenCode session on a task and return immediately. It runs on your model, unless you name a model or a " +
84
86
  "variant (reasoning effort) fit for the task. The session reports " +
85
87
  "back with courier_send, which wakes this session, and you are told if its turn fails or ends without a report " +
86
- "instead, it waits for a permission or it asks a question. Give a batch of parallel sessions one group name to " +
88
+ "instead, or it waits for a permission or asks a question (at the top of the tree; below it they go to the top). " +
89
+ "Give a batch of parallel sessions one group name to " +
87
90
  "be woken once, with all their reports, instead of once per report. DO NOT poll it or call courier_children in a " +
88
91
  "loop: once you have started the sessions you need, end your turn by replying without calling more tools; each " +
89
- "report, or each group's reports, starts a new turn in which you carry on. For long tasks, also courier_later a " +
90
- "check-in for yourself in case it never reports, and courier_cancel it when it does.",
92
+ "report, or each group's reports, starts a new turn in which you carry on. For a long task or batch, also " +
93
+ "courier_later one check-in for yourself per group (per session when ungrouped) in case the reports never come, " +
94
+ "and courier_cancel it when they arrive: a report does not cancel it.",
91
95
  input: SpawnInput,
92
96
  execute: async (input, context) => {
93
97
  const child = await spawn(ports.courier, context.sessionID, input).catch(rethrow("courier_spawn"));
package/dist/tui.js CHANGED
@@ -62,8 +62,8 @@ var memoryRpc = Rpc.define({
62
62
 
63
63
  // src/kernel/protocol.ts
64
64
  var STATUSES = ["done", "partial", "blocked", "failed"];
65
- var SPLIT_LINE = "split: no, because \u2026; considered: <the parts you weighed> or split: <n> parts: \u2026 (one phrase per part)";
66
- var ONE_PART_LINE = "split: 1 part: <the task>, to keep my context";
65
+ var SPLIT_LINE = "`split: no, because \u2026; considered: <the parts you weighed>` or `split: <n> parts: \u2026` (one phrase per part)";
66
+ var ONE_PART_LINE = "`split: 1 part: <the task>, to keep my context`";
67
67
  var ROOT_SPLIT_LINE = `${SPLIT_LINE} or ${ONE_PART_LINE}`;
68
68
 
69
69
  // src/memory/domain.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-courier",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Multi-session orchestration for OpenCode V2: spawn child sessions, be woken by them. No polling.",
5
5
  "license": "MIT",
6
6
  "author": "Ivo Pogace <71701666+ivopogace@users.noreply.github.com>",