opencode-courier 0.11.0 → 0.12.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
@@ -18,7 +18,7 @@ A narrated tour of the terminal UI: the tree panel, the asks inbox, failing chil
18
18
  ## Quickstart
19
19
 
20
20
  ```bash
21
- npm install -g @opencode/cli@2.1.3 # OpenCode V2, the version courier is tested on
21
+ npm install -g @opencode/cli@2.1.4 # OpenCode V2, the version courier is tested on
22
22
  opencode plugin add opencode-courier
23
23
  opencode
24
24
  ```
@@ -38,7 +38,8 @@ prompt again.
38
38
  OpenCode's own [`subagent` tool](https://opencode.ai/v2/docs/agents) hands back a child's final
39
39
  answer. Use it when one result is all you need. Use courier for more:
40
40
 
41
- - **Messages mid-run.** A child reports progress or asks its parent at any time.
41
+ - **Messages mid-run.** A child reports progress or asks its parent at any time, and its parent
42
+ passes a finding that changes the other parts on to the children it affects.
42
43
  - **Waking an idle session.** A message, a schedule or a GitHub webhook starts a new turn.
43
44
  - **Parallel edits.** `isolate: true` gives a child its own git worktree and branch to merge.
44
45
  - **Asks reach you.** A child's permission requests and questions come to you through the top
@@ -60,10 +61,10 @@ split a task and how to check and merge the parts. Every detail: [the reference]
60
61
  ## Install
61
62
 
62
63
  Requires OpenCode V2: the `opencode` command from `@opencode/cli`. Each release is tested against
63
- one OpenCode version; this one is tested on **OpenCode 2.1.3** (check with `opencode --version`).
64
+ one OpenCode version; this one is tested on **OpenCode 2.1.4** (check with `opencode --version`).
64
65
 
65
66
  ```bash
66
- npm install -g @opencode/cli@2.1.3
67
+ npm install -g @opencode/cli@2.1.4
67
68
  opencode plugin add opencode-courier
68
69
  ```
69
70
 
@@ -76,13 +77,13 @@ name, without a version: OpenCode offers updates only for an entry without an ex
76
77
  <!-- compat-table:start rows=3: generated by script/compat-table.ts from the release tags -->
77
78
  | opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
78
79
  |---|---|
80
+ | 0.11.1, 0.12.0 | 2.1.4 |
79
81
  | 0.11.0 | 2.1.3 |
80
82
  | 0.10.2, 0.10.3 | 2.1.2 |
81
- | 0.10.1 | 2.1.0 |
82
83
  <!-- compat-table:end -->
83
84
 
84
85
  Every release: [the full table](docs/reference.md#the-opencode-version). The server part loads on
85
- any V2 host from 2.0.4 on. The terminal part is tested on OpenCode 2.1.3 only.
86
+ any V2 host from 2.0.4 on. The terminal part is tested on OpenCode 2.1.4 only.
86
87
 
87
88
  ## Options
88
89
 
@@ -97,11 +98,11 @@ Set the server options on the plugin's entry in the **global** config,
97
98
 
98
99
  | Option | Default | |
99
100
  |---|---|---|
100
- | `maxDepth` | `3` | How deep a tree goes. Your session is depth 0. A session at `maxDepth` cannot start sessions. |
101
+ | `maxDepth` | `5` | How deep a tree goes. Your session is depth 0. A session at `maxDepth` cannot start sessions. |
101
102
  | `maxChildren` | `5` | Live children one session may have. |
102
- | `maxTotal` | `20` | Live spawned sessions in one tree. |
103
+ | `maxTotal` | `30` | Live spawned sessions in one tree. |
103
104
  | `memory` | `true` | Keep a record of each session tree for later orchestrators ([memory](docs/reference.md#memory)). |
104
- | `nativeChildren` | `false` | Create non-isolated children as OpenCode subagents ([native children](docs/reference.md#native-child-sessions)). |
105
+ | `nativeChildren` | `false` | The default for a spawn that does not set `native`: `true` creates non-isolated children as OpenCode subagents ([native children](docs/reference.md#native-child-sessions)). |
105
106
  | `webhook` | off | Start the webhook receiver ([webhooks](docs/reference.md#webhooks)). |
106
107
 
107
108
  The terminal part reads its options only from `~/.config/opencode/cli.json`, in the same entry
@@ -111,7 +112,7 @@ form. Its one option, `"animations": false`, keeps the tree panel still.
111
112
 
112
113
  | Tool | Does |
113
114
  |---|---|
114
- | `courier_spawn` | Starts a child session with a task and returns at once. `isolate`, `group`, `agent`, `title`. |
115
+ | `courier_spawn` | Starts a child session with a task and returns at once. `isolate`, `group`, `agent`, `model`, `variant`, `native`, `title`. |
115
116
  | `courier_send` | Messages a session, waking it if idle. With a `status`, it is a child's report. |
116
117
  | `courier_status` / `courier_children` / `courier_tree` | One look at a session, at the sessions it started, or at its whole tree. |
117
118
  | `courier_stop` | Stops a session below you and everything under it. |
@@ -143,6 +144,9 @@ More: [in the terminal](docs/reference.md#in-the-terminal).
143
144
  - Choose a web search provider before children search, or the first one shows you a form.
144
145
  - Use `isolate: true` when children edit files in parallel. The worktree starts from the last
145
146
  commit, so keep providers in the global config or commit `opencode.json`.
147
+ - To steer a running tree, open a child from the `/courier tree` panel, tell it what you found and ask
148
+ it to pass that to its parent, or message the parent directly
149
+ ([details](docs/reference.md#steering-a-running-tree)).
146
150
  - For a long-running child, schedule a `courier_later` check-in, in case the server stops.
147
151
 
148
152
  ## Contributing
package/dist/index.js CHANGED
@@ -91,7 +91,16 @@ export const courier = () => Plugin.define({
91
91
  directory: ctx.location.directory,
92
92
  projectID: ctx.location.project.id,
93
93
  now: Date.now,
94
- windows: async () => (await ctx.model.list()).data.map((model) => ({ providerID: model.providerID, id: model.id, context: model.limit.context })),
94
+ models: async () => (await ctx.model.list()).data.map((model) => ({
95
+ providerID: model.providerID,
96
+ id: model.id,
97
+ context: model.limit.context,
98
+ variants: model.variants.map((variant) => variant.id),
99
+ })),
100
+ defaultModel: async () => {
101
+ const { data } = await ctx.model.default();
102
+ return data ? { providerID: data.providerID, id: data.id } : undefined;
103
+ },
95
104
  head: headOf,
96
105
  dirty: dirtyOf,
97
106
  pending: async (sessionID) => {
@@ -142,8 +151,8 @@ export const courier = () => Plugin.define({
142
151
  const swept = sweepRecords(ctx.storage, Date.now()).catch((error) => log(`courier memory sweep: ${String(error)}`));
143
152
  // The windows the memory reads each part's context use by; a tree whose top runs in a location with memory off is not recorded.
144
153
  const windowed = ports
145
- .windows()
146
- .then((windows) => windows.forEach((window) => memoryState.windows.set(`${window.providerID}/${window.id}`, window)))
154
+ .models()
155
+ .then((models) => models.forEach(({ providerID, id, context }) => memoryState.windows.set(`${providerID}/${id}`, { providerID, id, context })))
147
156
  .catch((error) => log(`courier memory: cannot list the models' context windows: ${String(error)}`));
148
157
  if (!memory)
149
158
  memoryState.off.add(ctx.location.directory);
@@ -20,7 +20,8 @@ export function addMemoryTools(tools, ports) {
20
20
  tools.add({
21
21
  name: RECALL_TOOL,
22
22
  options: { codemode: false },
23
- description: "Look once at what earlier session trees in this project did, before you decide whether and how to split a task: " +
23
+ description: "Look once at what earlier session trees in this project did, at the root, before you decide whether and how to split " +
24
+ "a task; a session another session started reads none, since the root weighed the records before it split. The listing: " +
24
25
  "one line per tree, those the person pinned first and then newest first, with how its top split the task, how each part went (its status, how long it ran, " +
25
26
  "what it cost, how full its context got) and whether a spawn was refused; with sessionID, one tree's record in full. " +
26
27
  "Records hold no report bodies or files. Call it once at the start, not in a loop, and not to check on sessions you " +
package/dist/skill.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { Plugin } from "@opencode/plugin";
3
3
  export declare const SKILL_DESCRIPTION: string;
4
4
  /**
5
5
  * The skill's text; it names no limits, which the role part and the brief give for the session's own tree.
6
- * Steps 3, 4 and 6 repeat the brief's split rule on purpose: the root has no brief, so only the skill gives it.
6
+ * Steps 1, 3, 4 and 6 repeat the brief's split rule on purpose: the root has no brief, so only the skill gives it.
7
7
  */
8
8
  export declare const SKILL_CONTENT: string;
9
9
  /** The editor OpenCode hands a skill transform. */
package/dist/skill.js CHANGED
@@ -1,60 +1,115 @@
1
1
  import { ONE_PART_LINE, SKILL_ID, SPLIT_LINE } from "./kernel/protocol.js";
2
2
  // The courier-orchestrate skill: the playbook a session loads with its skill tool.
3
3
  /** Why a model loads the skill, listed with every session's available skills. */
4
- export const SKILL_DESCRIPTION = "Load at the start of any implementation task that may have independent parts, or when a session started you, to " +
5
- "decide whether to split it across OpenCode sessions with courier_spawn and state the decision, and how: plan the " +
6
- "parts, start them, end your turn, read the reports, verify the work, integrate it and report upward, and what to do " +
7
- "when a part goes wrong.";
4
+ export const SKILL_DESCRIPTION = "Load at the start of any implementation task that may have independent parts, or when a session started you, if " +
5
+ "courier_spawn is among your tools: how to decide whether to split it across OpenCode sessions and state the decision, " +
6
+ "plan and start the parts, end your turn, verify and integrate the reports, report upward, and handle a part gone wrong.";
8
7
  /**
9
8
  * The skill's text; it names no limits, which the role part and the brief give for the session's own tree.
10
- * Steps 3, 4 and 6 repeat the brief's split rule on purpose: the root has no brief, so only the skill gives it.
9
+ * Steps 1, 3, 4 and 6 repeat the brief's split rule on purpose: the root has no brief, so only the skill gives it.
11
10
  */
12
11
  export const SKILL_CONTENT = [
13
12
  "How to split a task across OpenCode sessions with opencode-courier, and bring the results back up. Your brief, or the " +
14
- "opencode-courier role line in your system prompt, names your depth in the session tree and its limits.",
13
+ "opencode-courier role line in your system prompt, names your depth in the session tree and its limits. If " +
14
+ "courier_spawn is not among your tools, you cannot split: do the task yourself, and report upward as your brief says " +
15
+ "if a session started you.",
15
16
  "",
16
- "1. Decide: split or do. First call courier_recall once: it lists how earlier trees in this project were split and how " +
17
- "each part went, so weigh that; one call, before you decide, never in a loop. Split when the task has 2 or more " +
18
- "independent, substantial parts touching separate files or areas; do it yourself when it is small, sequential or " +
19
- "tightly coupled. What you read and write yourself stays in your context for every later turn, so keep the deciding, " +
20
- "checking and integrating, and hand off the long reading and writing. Only the root, the session nobody started, may " +
21
- "start exactly one session for the whole task, and only when the task is heavy (long reading, many edits, long runs) " +
22
- "and its own context is worth keeping: it still verifies and integrates that session's report itself (steps 5 to 7), " +
23
- "and does a small task itself. Every other session never starts exactly one session for the whole task: it only adds " +
24
- "a hop, and a chain of single hops uses up the tree's depth. A part that is itself big and divisible is split again by " +
25
- "the session that gets it; at the deepest level a session does its part itself. " +
26
- `Say which in one line of your reply, before you start, so that a reasoned decision to do it yourself can be told ` +
17
+ "1. Decide: split or do.",
18
+ " - At the root, first call courier_recall once: it lists how earlier trees in this project were split and how each " +
19
+ "part went, so weigh that; one call, before you decide, never in a loop. Below the root, read none: the records " +
20
+ "describe whole trees, and the root weighed them before it split.",
21
+ " - Split when the task has 2 or more independent, substantial parts touching separate files or areas; do it yourself " +
22
+ "when it is small, sequential or tightly coupled. A part that is itself big and divisible is split again by the " +
23
+ "session that gets it. Several whole jobs in one prompt, such as 4 tickets, are that many parts: each session runs " +
24
+ "this playbook for its job, stages, review and all, so the tree below you repeats its shape once per job.",
25
+ " - What you read and write yourself stays in your context for every later turn, so keep the deciding, checking and " +
26
+ "integrating, and hand off the long reading and writing.",
27
+ " - Only the root, the session nobody started, may start exactly one session for the whole task, and only when the " +
28
+ "task is heavy (long reading, many edits, long runs) and its own context is worth keeping. It still verifies and " +
29
+ "integrates that session's report itself (steps 5 to 7), and does a small task itself.",
30
+ " - Every other session never starts exactly one session for the whole task: it only adds a hop, and a chain of single " +
31
+ "hops uses up the tree's depth.",
32
+ " - Say which in one line of your reply, before you start, so that a reasoned decision to do it yourself can be told " +
27
33
  `from one never made: ${SPLIT_LINE}; the root has a third form, ${ONE_PART_LINE}.`,
28
- " When the core is coupled: an implementation task with one tightly coupled core usually still has independent parts: " +
29
- "finding out what the change needs (a session with isolate: false reads the code, or a library's or a host's source, " +
30
- "tries things and reports what it found), its e2e scenario, its documentation, sometimes a second test file, and a " +
31
- "review of the core by a session with fresh context, which reads the committed diff against the task and the " +
32
- "repository's rules and reports its findings (isolate: false, it edits nothing). Weigh each of them. When the other " +
33
- "parts depend on names and texts the core fixes, split in stages: the core first, yourself when it is small or by one " +
34
- "session when it is big, merged and committed before the rest start; then, if 2 or more parts remain, one session per " +
35
- "remaining part, in one group. A staged core is one part of the task, not the whole task, so the rule against starting exactly one " +
36
- "session for the whole task does not apply to it when 2 or more parts follow it.",
34
+ " Parts a task can have, besides its edits, each one session where it is worth a context of its own:",
35
+ " - a scout: a session with isolate: false reads the code, or a library's or a host's source, tries things and " +
36
+ "reports what it found, before you plan;",
37
+ " - the implementers, isolate: true where two edit at once, each with its checks and what to report;",
38
+ " - its e2e scenario, its documentation, sometimes a second test file;",
39
+ " - a review stage, after the merge, when the changes are done and it is to see the whole: one review session with " +
40
+ "fresh context, which makes no fixes of its own. Put in its task the diff to read: when you are isolated, your own " +
41
+ "courier/<session> branch, committed first, against the commit your worktree started from; else your checkout's HEAD, " +
42
+ "with the merges committed, against the commit you started from, which you note before your first spawn. It may hand the " +
43
+ "reading to 2 or more reviewers (one group, each reads that diff from one angle and reports its findings), then " +
44
+ "starts one verifier with their reports: a fresh session that checks each finding against the diff, drops what " +
45
+ "does not hold, and starts one implementor for what holds, or 2 or more when the fixes are independent, isolate: " +
46
+ "true each then, merging their branches after (step 7); an implementor fixes what it should and refuses, with the " +
47
+ "reason, what it should not. Where the code is: when you work in the project's checkout, the review session, the " +
48
+ "verifier and a lone implementor go without isolate, and it commits in place; when you are isolated, start each of " +
49
+ "them with isolate: true, so that each worktree starts from the one above, and each merges the branch reported to " +
50
+ "it before it reports (step 7); reviewers only read and need no worktree. Each reports to the one that started " +
51
+ "it; the review session reports to " +
52
+ "you what was found, what held, what was fixed (the commits) and what was refused and why. It does not replace " +
53
+ "step 6: running the checks on the fixed tree stays yours, and so does settling a refusal you disagree with. Go " +
54
+ "through steps 3 to 7 once more for the stage, and do its work yourself when the levels below you are too few for " +
55
+ "it. One session for one stage, after a stage of 2 or more parts or one you did yourself, is a part of the task, " +
56
+ "like a staged core, not the whole; a reviewer alone with nothing before it is not a stage.",
57
+ " When the core is coupled: an implementation task with one tightly coupled core usually still has parts from that " +
58
+ "list; weigh each of them. When the other parts depend on names and texts the core fixes, split in stages: the core " +
59
+ "first, yourself when it is small or by one session when it is big, merged and committed before the rest start; then, " +
60
+ "if 2 or more parts remain, one session per remaining part, in one group. A staged core is one part of the task, not " +
61
+ "the whole task, so the rule against starting exactly one session for the whole task does not apply to it when 2 or " +
62
+ "more parts follow it.",
37
63
  "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 " +
38
64
  "produce and where, how to check it, what to report. Say if it may split its part further, and to run its checks " +
39
- "through the project's scripts (step 6): each permission request of a session below you wakes you and waits on the person.",
65
+ "through the project's scripts (step 6): each permission request of a session below you wakes you and waits on the person. " +
66
+ "Name the skills it should load for its part, from those listed to you: it sees the same list, not your choice; one " +
67
+ "permission rule for skill covers their loads, as one covers the checks. Say in the task when the part is a whole job " +
68
+ "that delivers itself (step 7), since an isolated session's brief otherwise says its branch is for you to merge.",
40
69
  "3. Start them: one courier_spawn per part that runs at once, all with the same group name, so you are woken once with every report; " +
41
70
  "isolate: true for each part that edits files in parallel with another (commit your own work first if you are isolated " +
42
- "yourself). For long work, courier_later a check-in for yourself, and courier_cancel it when the reports arrive.",
71
+ "yourself). When each part runs a review stage of its own, count 6 live sessions per part against maxTotal (a part, " +
72
+ "its review session and its reviewers are live together at the peak): start the parts that fit in one group, and the " +
73
+ "rest in a group of their own once the first group has reported. A job that depends on another starts after that one " +
74
+ "is done, as a stage: merged into your checkout, or, when it was delivered instead (step 7), started with isolate: " +
75
+ "true and told the delivering session's courier/<session> branch, from its report, to merge into its worktree first. " +
76
+ "Leave model and variant unset, so a part runs on your own model, unless the part plainly calls for another: a " +
77
+ "cheaper model or a lower variant for a small, well-specified part (a rename, a lookup), a stronger model or a higher " +
78
+ "variant for a design question or a hard bug. " +
79
+ "native: true for a part nobody needs to open on its own: it reads, checks or looks something up, reports once and is " +
80
+ "done; leave it off for a part the person may want to open, or that should outlive you. " +
81
+ "For long work, courier_later a check-in for yourself, and courier_cancel it when the reports arrive.",
43
82
  "4. End your turn: reply without calling more tools. Do not poll with courier_status, courier_children, courier_tree or " +
44
83
  "sleep. The group's reports, a blocked report, a question, a permission request, a failed turn or a silent end each start " +
45
- "a new turn for you.",
84
+ "a new turn for you. So does a session's message without a status, and one from the person or from the session that " +
85
+ "started you.",
86
+ " Answering from below: follow the notice. For a permission request, propose once with courier_answer, or reject with " +
87
+ "the reason when the person has told you to refuse it or it plainly goes against what they asked; the person decides, " +
88
+ "in OpenCode's prompt in your session. For a question, ask the person with your question tool, with the questions the " +
89
+ "notice gives; their answer goes on by itself. When the notice says OpenCode already shows it to the person, call " +
90
+ "nothing unless they give you their answer. Never answer for them.",
91
+ " Passing a message on: when it changes what other parts need (an assumption of the split that is wrong, a shared file " +
92
+ "or API that behaves otherwise, a decision of the person's), pass it on at once with courier_send, only to the sessions " +
93
+ "it affects, before they go further, then end your turn again; if it breaks the split, courier_stop the parts it breaks " +
94
+ "and plan them again, or send them a corrected task. If a session started you and its other parts need it too, send it " +
95
+ "up to that session as well, without a status. Progress that changes nothing for the others goes to no one.",
46
96
  "5. Read each report by its status and its artifacts (branch, commits, files, checks). done: verify it. partial: what is " +
47
97
  "left and why; have the session finish it with a courier_send, or do it yourself. blocked: make the decision it asks for " +
48
98
  "and send it with courier_send; if only the person can make it, ask them with your question tool and pass the answer on. " +
49
- "failed: read the error; message the session to try again, start a replacement, or carry on without it.",
50
- "6. Verify first-hand: open the files, run the checks. Never hand the checking to another session: a review session's " +
51
- "findings are a report to act on, and running the checks is still yours. Run checks through " +
99
+ "failed: read the error; message the session to try again, start a replacement, or carry on without it. A review " +
100
+ "stage's report lists what was found, held, fixed and refused: read the refusals, fix or settle yourself any you " +
101
+ "disagree with, and run the checks on the fixed tree.",
102
+ "6. Verify first-hand: open the files, run the checks. Never hand the checking to another session: a review stage " +
103
+ "verifies and fixes below you, and running the checks on what it changed is still yours. Run checks through " +
52
104
  "the project's scripts (bun run <script>, npm run <script>), not ./node_modules/.bin/<tool>, so that one permission " +
53
105
  "rule covers them: your own permission prompts are relayed nowhere, and each one stalls the whole tree.",
54
106
  "7. Integrate: merge each reported branch into your own checkout (git merge courier/<session>), resolve conflicts " +
55
- "yourself, run the checks once more, then courier_cleanup the isolated sessions you are done with.",
107
+ "yourself, run the checks once more, then courier_cleanup the isolated sessions you are done with. When the parts are " +
108
+ "whole jobs with a deliverable each (a ticket with its own branch or pull request), the session delivers it and " +
109
+ "reports the link in its message, still naming its courier/<session> branch when it is isolated; you verify each " +
110
+ "delivery and merge nothing of it into your checkout.",
56
111
  "8. Report upward if a session started you: one courier_send to it, with one status for the whole (done only when every " +
57
- "part is verified and integrated; partial, blocked or failed otherwise) and the artifacts. At the top, before you reply " +
112
+ "part is verified and integrated, or verified and delivered; partial, blocked or failed otherwise) and the artifacts. At the top, before you reply " +
58
113
  "to the person: if there is something the next tree in this project should know about running a tree like this one (how " +
59
114
  "it was split, what a part got wrong and what caught it, what a brief should have said, what the person decided), one " +
60
115
  "courier_note with that one line; none when there is nothing. Name the job it comes from and the reason, so that a " +
@@ -17,7 +17,7 @@ export declare function readLimits(options: unknown): {
17
17
  limits: Limits;
18
18
  problems: string[];
19
19
  };
20
- /** The `nativeChildren` option: whether a child started without isolate gets OpenCode's own `parentID`; off unless `true`. */
20
+ /** The `nativeChildren` option: whether a child started without isolate or `native` gets OpenCode's own `parentID`; off unless `true`. */
21
21
  export declare function readNativeChildren(options: unknown): {
22
22
  nativeChildren: boolean;
23
23
  problems: string[];
@@ -91,9 +91,27 @@ export interface RosterEntry {
91
91
  readonly reports?: "status";
92
92
  /** The join group courier_spawn put it in, whose release holds its report; absent for a child in none. */
93
93
  readonly group?: string;
94
- /** Created with OpenCode's own `parentID` (the `nativeChildren` option), so OpenCode shows its asks in its parent's view. */
94
+ /** Created with OpenCode's own `parentID` (courier_spawn's `native`, else the `nativeChildren` option), so OpenCode shows its asks in its parent's view. */
95
95
  readonly native?: true;
96
+ /** The model it was started on, as `modelName` writes it, when that is not the model its parent was on. */
97
+ readonly model?: string;
96
98
  }
99
+ /** How a child was started, as courier_children and the panel show it: isolated, native when it is, and its model when not its parent's. */
100
+ export declare function startedAs(entry: RosterEntry): {
101
+ model?: string | undefined;
102
+ native?: true | undefined;
103
+ isolated: boolean;
104
+ };
105
+ /** A model as a session names it: its provider, its id, and its variant, which `"default"` and none both leave to the provider. */
106
+ export interface ModelRef {
107
+ readonly providerID: string;
108
+ readonly id: string;
109
+ readonly variant?: string;
110
+ }
111
+ /** `provider/model` as `courier_spawn` takes it: one slash, nothing empty on either side. */
112
+ export declare function parseModelName(text: string): Pick<ModelRef, "providerID" | "id"> | undefined;
113
+ /** A model as the roster, the panel and the results name it: `provider/model`, with `#variant` for a variant that is not the default. */
114
+ export declare function modelName(model: ModelRef): string;
97
115
  /** `lineage` over roster entries already read. */
98
116
  export declare function lineageIn(entries: ReadonlyArray<RosterEntry>, sessionID: string): RosterEntry[];
99
117
  /** Roster entries by their session, for `lineageBy`. */
@@ -1,5 +1,5 @@
1
1
  import { millis, obj } from "../platform/json.js";
2
- export const DEFAULT_LIMITS = { maxDepth: 3, maxChildren: 5, maxTotal: 20 };
2
+ export const DEFAULT_LIMITS = { maxDepth: 5, maxChildren: 5, maxTotal: 30 };
3
3
  /** The limits in the plugin's options; one that is not a positive integer keeps its default and is named in `problems`. */
4
4
  export function readLimits(options) {
5
5
  const given = obj(options);
@@ -16,7 +16,7 @@ export function readLimits(options) {
16
16
  const limits = { maxDepth: read("maxDepth"), maxChildren: read("maxChildren"), maxTotal: read("maxTotal") };
17
17
  return { limits, problems };
18
18
  }
19
- /** The `nativeChildren` option: whether a child started without isolate gets OpenCode's own `parentID`; off unless `true`. */
19
+ /** The `nativeChildren` option: whether a child started without isolate or `native` gets OpenCode's own `parentID`; off unless `true`. */
20
20
  export function readNativeChildren(options) {
21
21
  const value = obj(options).nativeChildren;
22
22
  if (value === undefined || typeof value === "boolean")
@@ -70,6 +70,22 @@ export function cut(nodes, max) {
70
70
  }
71
71
  return { kept: nodes.filter((node) => kept.has(node.sessionID)), leftOut: nodes.length - kept.size };
72
72
  }
73
+ /** How a child was started, as courier_children and the panel show it: isolated, native when it is, and its model when not its parent's. */
74
+ export function startedAs(entry) {
75
+ return { isolated: entry.isolated, ...(entry.native ? { native: true } : {}), ...(entry.model ? { model: entry.model } : {}) };
76
+ }
77
+ /** `provider/model` as `courier_spawn` takes it: one slash, nothing empty on either side. */
78
+ export function parseModelName(text) {
79
+ const slash = text.indexOf("/");
80
+ if (slash <= 0 || slash === text.length - 1)
81
+ return undefined;
82
+ return { providerID: text.slice(0, slash), id: text.slice(slash + 1) };
83
+ }
84
+ /** A model as the roster, the panel and the results name it: `provider/model`, with `#variant` for a variant that is not the default. */
85
+ export function modelName(model) {
86
+ const variant = model.variant && model.variant !== "default" ? `#${model.variant}` : "";
87
+ return `${model.providerID}/${model.id}${variant}`;
88
+ }
73
89
  /** `lineage` over roster entries already read. */
74
90
  export function lineageIn(entries, sessionID) {
75
91
  return lineageBy(bySession(entries), sessionID);
@@ -0,0 +1,18 @@
1
+ import { type ModelRef } from "./domain.js";
2
+ import type { CourierPorts } from "./ports.js";
3
+ /** What courier_spawn says of the model: `provider/model`, and the variant of whichever model is chosen. */
4
+ export interface ModelChoice {
5
+ readonly model?: string;
6
+ readonly variant?: string;
7
+ }
8
+ /**
9
+ * The model a child runs on: the `model` named, else the agent's, else the one its parent runs on, with the `variant`
10
+ * named; `parent` is the parent's own, for the roster to tell a child on another one.
11
+ */
12
+ export declare function chooseModel(ports: CourierPorts, parentID: string, choice: ModelChoice, agent: string | undefined): Promise<{
13
+ model: undefined;
14
+ parent: ModelRef | undefined;
15
+ } | {
16
+ model: ModelRef;
17
+ parent: ModelRef | undefined;
18
+ }>;
@@ -0,0 +1,65 @@
1
+ import { modelName, parseModelName } from "./domain.js";
2
+ import { modelFormatRefusal, unknownModelRefusal, unknownVariantRefusal, variantWithoutModelRefusal } from "./text.js";
3
+ /**
4
+ * The model a child runs on: the `model` named, else the agent's, else the one its parent runs on, with the `variant`
5
+ * named; `parent` is the parent's own, for the roster to tell a child on another one.
6
+ */
7
+ export async function chooseModel(ports, parentID, choice, agent) {
8
+ // Some models send null, or an empty string, for an optional field they leave out.
9
+ const named = trimmed(choice.model);
10
+ const variant = trimmed(choice.variant);
11
+ const own = named === undefined ? undefined : parseModelName(named);
12
+ if (named !== undefined && !own)
13
+ throw new Error(modelFormatRefusal(named));
14
+ const parent = await parentModel(ports, parentID);
15
+ // An agent that cannot be looked up may name a model of its own, which OpenCode applies when none is passed.
16
+ const inherited = own ? undefined : await inheritedModel(ports, parent, agent).catch(() => undefined);
17
+ const base = own ?? inherited;
18
+ if (!base) {
19
+ if (variant !== undefined)
20
+ throw new Error(variantWithoutModelRefusal(variant, agent));
21
+ return { model: undefined, parent };
22
+ }
23
+ const chosen = variant ?? base.variant;
24
+ const model = { providerID: base.providerID, id: base.id, ...(chosen !== undefined ? { variant: chosen } : {}) };
25
+ if (own || variant !== undefined)
26
+ await check(ports, model, own !== undefined);
27
+ return { model, parent };
28
+ }
29
+ /**
30
+ * The model the parent runs on: its session's, else OpenCode's default. A failed lookup must not keep a child on no
31
+ * particular model from starting, so it reads as none, and the child then runs on OpenCode's default.
32
+ */
33
+ async function parentModel(ports, parentID) {
34
+ const session = await ports.session.get({ sessionID: parentID }).catch(() => undefined);
35
+ if (!session)
36
+ return undefined;
37
+ return session.model ?? (await ports.defaultModel().catch(() => undefined));
38
+ }
39
+ /** The named agent's model, if it names one; else the parent's. */
40
+ async function inheritedModel(ports, parent, agent) {
41
+ if (!agent)
42
+ return parent;
43
+ const named = await ports.agent.get({ agentID: agent, location: { directory: ports.directory } });
44
+ return named.data.model ?? parent;
45
+ }
46
+ /**
47
+ * Refuses a model OpenCode does not list, or a variant its model has not; `"default"` is every model's. A listing that
48
+ * cannot be read checks nothing: OpenCode refuses what it does not know at the child's first turn, which is reported.
49
+ */
50
+ async function check(ports, model, named) {
51
+ let known;
52
+ try {
53
+ known = await ports.models();
54
+ }
55
+ catch (error) {
56
+ ports.log(`courier_spawn: cannot list the models to check ${modelName(model)} against: ${String(error)}`);
57
+ return;
58
+ }
59
+ const found = known.find((item) => item.providerID === model.providerID && item.id === model.id);
60
+ if (!found)
61
+ throw new Error(unknownModelRefusal(model, known, named));
62
+ if (model.variant !== undefined && model.variant !== "default" && !found.variants.includes(model.variant))
63
+ throw new Error(unknownVariantRefusal(model, found.variants));
64
+ }
65
+ const trimmed = (value) => (typeof value === "string" ? value.trim() || undefined : (value ?? undefined));
@@ -1,3 +1,4 @@
1
+ import { startedAs } from "./domain.js";
1
2
  import { lastStatus, stoppedSincePrompt } from "./report.js";
2
3
  import { standingIn } from "./status.js";
3
4
  import { childStore } from "./store.js";
@@ -80,7 +81,7 @@ async function row(ports, windows, { entry, depth, info, running, endedAt, pendi
80
81
  ...(stopped ? { stopped } : {}),
81
82
  ...reportRow(report, status),
82
83
  ...(entry.group && standing ? { group: { name: entry.group, standing } } : {}),
83
- isolated: entry.isolated,
84
+ ...startedAs(entry),
84
85
  created: info?.time.created ?? entry.createdAt,
85
86
  ...(info && !running && endedAt !== undefined ? { idle: endedAt } : {}),
86
87
  ...(info ? { cost: info.cost } : {}),
@@ -96,7 +97,7 @@ async function row(ports, windows, { entry, depth, info, running, endedAt, pendi
96
97
  export async function panel(ports, rootID) {
97
98
  const { kept, leftOut } = await readTree(ports, rootID);
98
99
  const stalled = await stalledAmong(ports, [rootID, ...kept.map((node) => node.entry.sessionID)]);
99
- const windows = await ports.windows().catch(() => []);
100
+ const windows = await ports.models().catch(() => []);
100
101
  const rows = await Promise.all(kept.map((node) => row(ports, windows, node, stalled.get(node.entry.sessionID))));
101
102
  const own = stalled.get(rootID);
102
103
  return { rows, ...(leftOut ? { truncated: leftOut } : {}), ...(own !== undefined ? { stalled: own } : {}) };
@@ -5,8 +5,8 @@
5
5
  import type { Plugin } from "@opencode/plugin";
6
6
  import type { Status } from "../kernel/protocol.js";
7
7
  import type { Storage } from "../platform/storage.js";
8
- import type { Limits, Member, RosterEntry } from "./domain.js";
9
- export type { Artifacts, HeldReport, LeftMember, Limits, Member, RosterEntry, Standing } from "./domain.js";
8
+ import type { Limits, Member, ModelRef, RosterEntry } from "./domain.js";
9
+ export type { Artifacts, HeldReport, LeftMember, Limits, Member, ModelRef, RosterEntry, Standing } from "./domain.js";
10
10
  type Context = Plugin.Context;
11
11
  export type RosterStorage = Storage;
12
12
  /** How a session's wait for a report ended: it reported to its parent, or its turn failed or was interrupted. */
@@ -168,10 +168,12 @@ export interface CourierPorts {
168
168
  readonly pending: (sessionID: string) => Promise<ReadonlyArray<Pending>>;
169
169
  /** The forms a session shows, which only the person can answer: web search's, a plugin's; not the question tool's. */
170
170
  readonly forms: (sessionID: string) => Promise<ReadonlyArray<ShownForm>>;
171
- /** The context windows of the models available, by provider and model id, as the panel reads a session's context use. */
172
- readonly windows: () => Promise<ReadonlyArray<ModelWindow>>;
171
+ /** The models available, each with its context window and variants, as courier_spawn checks a choice and the panel reads a session's context use. */
172
+ readonly models: () => Promise<ReadonlyArray<KnownModel>>;
173
+ /** OpenCode's default model here, which a session with no model of its own runs on; undefined when there is none. */
174
+ readonly defaultModel: () => Promise<ModelRef | undefined>;
173
175
  readonly limits: Limits;
174
- /** The `nativeChildren` option: a child started without isolate is created with OpenCode's own `parentID`. */
176
+ /** The `nativeChildren` option: a child started without isolate and without `native` is created with OpenCode's own `parentID`. */
175
177
  readonly nativeChildren?: boolean;
176
178
  readonly gate: SpawnGate;
177
179
  /** The depths the `context` hook remembers; a parent's is dropped when it starts a child. */
@@ -180,12 +182,16 @@ export interface CourierPorts {
180
182
  readonly nudge: () => void;
181
183
  readonly log: (message: string) => void;
182
184
  }
183
- /** A model's context window in tokens, as `CourierPorts.windows` lists it. */
185
+ /** A model's context window in tokens, as the panel and the memory read a session's context use by. */
184
186
  export interface ModelWindow {
185
187
  readonly providerID: string;
186
188
  readonly id: string;
187
189
  readonly context: number;
188
190
  }
191
+ /** A model OpenCode lists, as `CourierPorts.models` gives it: its window, and the ids of its variants. */
192
+ export interface KnownModel extends ModelWindow {
193
+ readonly variants: ReadonlyArray<string>;
194
+ }
189
195
  /** A form a session shows, as `CourierPorts.forms` lists it; `person/` keeps them. */
190
196
  export interface ShownForm {
191
197
  readonly id: string;
@@ -87,6 +87,8 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
87
87
  readonly status?: string | undefined;
88
88
  } | undefined;
89
89
  readonly idle?: number | undefined;
90
+ readonly native?: boolean | undefined;
91
+ readonly model?: string | undefined;
90
92
  readonly tool?: {
91
93
  readonly name: string;
92
94
  readonly at: number;
@@ -130,6 +132,8 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
130
132
  readonly status?: string | undefined;
131
133
  } | undefined;
132
134
  readonly idle?: number | undefined;
135
+ readonly native?: boolean | undefined;
136
+ readonly model?: string | undefined;
133
137
  readonly tool?: {
134
138
  readonly name: string;
135
139
  readonly at: number;
@@ -159,6 +163,8 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
159
163
  readonly standing: import("effect/Schema").String;
160
164
  }>>;
161
165
  readonly isolated: import("effect/Schema").Boolean;
166
+ readonly native: import("effect/Schema").optionalKey<import("effect/Schema").Boolean>;
167
+ readonly model: import("effect/Schema").optionalKey<import("effect/Schema").String>;
162
168
  readonly created: import("effect/Schema").Number;
163
169
  readonly idle: import("effect/Schema").optionalKey<import("effect/Schema").Number>;
164
170
  readonly cost: import("effect/Schema").optionalKey<import("effect/Schema").Number>;
@@ -60,6 +60,10 @@ declare const Row: Schema.Struct<{
60
60
  readonly standing: Schema.String;
61
61
  }>>;
62
62
  readonly isolated: Schema.Boolean;
63
+ /** Set when it is OpenCode's subagent of its parent (a native child), which OpenCode's session lists leave out. */
64
+ readonly native: Schema.optionalKey<Schema.Boolean>;
65
+ /** The model it was started on, `provider/model` with `#variant` when one is set, when that is not its parent's. */
66
+ readonly model: Schema.optionalKey<Schema.String>;
63
67
  readonly created: Schema.Number;
64
68
  /** When its last turn ended, if it is not running. */
65
69
  readonly idle: Schema.optionalKey<Schema.Number>;
@@ -117,6 +121,10 @@ declare const Tree: Schema.Struct<{
117
121
  readonly standing: Schema.String;
118
122
  }>>;
119
123
  readonly isolated: Schema.Boolean;
124
+ /** Set when it is OpenCode's subagent of its parent (a native child), which OpenCode's session lists leave out. */
125
+ readonly native: Schema.optionalKey<Schema.Boolean>;
126
+ /** The model it was started on, `provider/model` with `#variant` when one is set, when that is not its parent's. */
127
+ readonly model: Schema.optionalKey<Schema.String>;
120
128
  readonly created: Schema.Number;
121
129
  /** When its last turn ended, if it is not running. */
122
130
  readonly idle: Schema.optionalKey<Schema.Number>;
@@ -258,6 +266,8 @@ export declare const treeRpc: {
258
266
  readonly status?: string | undefined;
259
267
  } | undefined;
260
268
  readonly idle?: number | undefined;
269
+ readonly native?: boolean | undefined;
270
+ readonly model?: string | undefined;
261
271
  readonly tool?: {
262
272
  readonly name: string;
263
273
  readonly at: number;
@@ -301,6 +311,8 @@ export declare const treeRpc: {
301
311
  readonly status?: string | undefined;
302
312
  } | undefined;
303
313
  readonly idle?: number | undefined;
314
+ readonly native?: boolean | undefined;
315
+ readonly model?: string | undefined;
304
316
  readonly tool?: {
305
317
  readonly name: string;
306
318
  readonly at: number;
@@ -341,6 +353,10 @@ export declare const treeRpc: {
341
353
  readonly standing: Schema.String;
342
354
  }>>;
343
355
  readonly isolated: Schema.Boolean;
356
+ /** Set when it is OpenCode's subagent of its parent (a native child), which OpenCode's session lists leave out. */
357
+ readonly native: Schema.optionalKey<Schema.Boolean>;
358
+ /** The model it was started on, `provider/model` with `#variant` when one is set, when that is not its parent's. */
359
+ readonly model: Schema.optionalKey<Schema.String>;
344
360
  readonly created: Schema.Number;
345
361
  /** When its last turn ended, if it is not running. */
346
362
  readonly idle: Schema.optionalKey<Schema.Number>;
package/dist/tree/rpc.js CHANGED
@@ -50,6 +50,10 @@ const Row = Schema.Struct({
50
50
  /** Its join group, and its standing there: `held`, `out`, how it left, or `released`. */
51
51
  group: Schema.optionalKey(Schema.Struct({ name: Schema.String, standing: Schema.String })),
52
52
  isolated: Schema.Boolean,
53
+ /** Set when it is OpenCode's subagent of its parent (a native child), which OpenCode's session lists leave out. */
54
+ native: Schema.optionalKey(Schema.Boolean),
55
+ /** The model it was started on, `provider/model` with `#variant` when one is set, when that is not its parent's. */
56
+ model: Schema.optionalKey(Schema.String),
53
57
  created: Schema.Number,
54
58
  /** When its last turn ended, if it is not running. */
55
59
  idle: Schema.optionalKey(Schema.Number),
@@ -5,12 +5,20 @@ export interface SpawnInput {
5
5
  readonly agent?: string;
6
6
  readonly isolate?: boolean;
7
7
  readonly group?: string;
8
+ /** `provider/model` to run on; left out, the agent's own model, else the caller's. */
9
+ readonly model?: string;
10
+ /** The variant of that model, its reasoning effort; left out, the chosen model's own. */
11
+ readonly variant?: string;
12
+ /** Whether the child is OpenCode's subagent of the caller; left out, the `nativeChildren` option decides. */
13
+ readonly native?: boolean;
8
14
  }
9
15
  /**
10
16
  * Creates a child session, hands it the task and returns at once; the child reports back with
11
17
  * courier_send. Refused past a limit, whether or not the `context` hook hid the tool.
12
18
  */
13
19
  export declare function spawn(ports: CourierPorts, parentID: string, input: SpawnInput): Promise<{
20
+ native?: boolean | undefined;
21
+ model?: string | undefined;
14
22
  uncommitted?: boolean | undefined;
15
23
  fromParent?: boolean | undefined;
16
24
  groupError?: string | undefined;
@@ -1,24 +1,14 @@
1
1
  import { describeFailure } from "../kernel/protocol.js";
2
- import { isGroupName } from "./domain.js";
2
+ import { isGroupName, modelName } from "./domain.js";
3
3
  import { joinGroup, leaveGroup } from "./group.js";
4
4
  import { admit } from "./limits.js";
5
+ import { chooseModel } from "./model.js";
5
6
  import { childStore, groupStore } from "./store.js";
6
- import { childBrief, childBranch } from "./text.js";
7
+ import { childBrief, childBranch, nativeIsolateRefusal } from "./text.js";
7
8
  function titleOf(task) {
8
9
  const line = task.trim().split("\n")[0] ?? "";
9
10
  return line.length > 60 ? `${line.slice(0, 57)}...` : line;
10
11
  }
11
- /**
12
- * The model a child runs on: its parent's, so it does not fall back to OpenCode's default, which
13
- * the parent may have been moved off for a reason. An agent that names its own model keeps it.
14
- */
15
- async function inheritedModel(ports, parentID, agent) {
16
- const parent = await ports.session.get({ sessionID: parentID });
17
- if (!parent.model || !agent)
18
- return parent.model;
19
- const named = await ports.agent.get({ agentID: agent, location: { directory: ports.directory } });
20
- return named.data.model ? undefined : parent.model;
21
- }
22
12
  /** Removes a worktree made for a child that was never created; one that cannot be removed stays. */
23
13
  async function dropWorktree(ports, directory) {
24
14
  try {
@@ -37,16 +27,19 @@ export async function spawn(ports, parentID, input) {
37
27
  const group = typeof input.group === "string" ? input.group.trim() || undefined : (input.group ?? undefined);
38
28
  if (group !== undefined && !isGroupName(group))
39
29
  throw new Error(`group must be a name of 1 to 60 letters, digits, dots, dashes or underscores, not ${JSON.stringify(group)}.`);
30
+ const native = typeof input.native === "boolean" ? input.native : undefined;
31
+ if (native && input.isolate)
32
+ throw new Error(nativeIsolateRefusal());
40
33
  const admitted = await admit(ports, parentID);
41
34
  try {
42
- return await start(ports, parentID, input, admitted, group);
35
+ return await start(ports, parentID, { ...input, native }, admitted, group);
43
36
  }
44
37
  finally {
45
38
  admitted.release();
46
39
  }
47
40
  }
48
41
  async function start(ports, parentID, input, admitted, group) {
49
- const { worktree, handedDown, ...child } = await create(ports, parentID, input);
42
+ const { worktree, handedDown, model, ...child } = await create(ports, parentID, input);
50
43
  admitted.reservation.sessionID = child.sessionID;
51
44
  const entry = {
52
45
  ...child,
@@ -56,6 +49,7 @@ async function start(ports, parentID, input, admitted, group) {
56
49
  reports: "status",
57
50
  ...(worktree ? { source: ports.directory, project: ports.projectID } : {}),
58
51
  ...(group ? { group } : {}),
52
+ ...(model ? { model } : {}),
59
53
  };
60
54
  const { rosterError, groupError } = await enrol(ports, entry, group);
61
55
  ports.roles.delete(parentID);
@@ -68,12 +62,16 @@ async function start(ports, parentID, input, admitted, group) {
68
62
  ...(groupError ? { groupError } : {}),
69
63
  ...(handedDown ? { fromParent: true } : {}),
70
64
  ...(handedDown?.uncommitted ? { uncommitted: true } : {}),
65
+ ...(model ? { model } : {}),
66
+ ...(entry.native ? { native: true } : {}),
71
67
  };
72
68
  }
73
- /** Creates the child's session, in a worktree of its own if asked, which goes again if the session cannot be created. */
69
+ /**
70
+ * Creates the child's session, in a worktree of its own if asked, which goes again if the session cannot be created.
71
+ * `model` names the child's model when it is not its parent's, for the roster and the result.
72
+ */
74
73
  async function create(ports, parentID, input) {
75
- // A failed lookup must not keep the child from starting; it then runs on OpenCode's default.
76
- const model = await inheritedModel(ports, parentID, input.agent).catch(() => undefined);
74
+ const { model, parent } = await chooseModel(ports, parentID, input, input.agent);
77
75
  const handedDown = input.isolate ? await parentWork(ports, parentID) : undefined;
78
76
  const worktree = input.isolate
79
77
  ? (await ports.worktree.create({ projectID: ports.projectID, ...(handedDown ? { from: handedDown.directory, branch: handedDown.head } : {}) })).directory
@@ -81,7 +79,7 @@ async function create(ports, parentID, input) {
81
79
  const base = worktree ? await ports.head(worktree) : undefined;
82
80
  const title = input.title ?? titleOf(input.task);
83
81
  // OpenCode takes a location or a parentID, never both, so an isolated child cannot be a native one.
84
- const native = ports.nativeChildren === true && !worktree;
82
+ const native = (input.native ?? ports.nativeChildren === true) && !worktree;
85
83
  const child = await ports.session
86
84
  .create({
87
85
  title,
@@ -106,6 +104,7 @@ async function create(ports, parentID, input) {
106
104
  handedDown,
107
105
  ...(base ? { base } : {}),
108
106
  ...(native ? { native: true } : {}),
107
+ ...(model && (!parent || modelName(model) !== modelName(parent)) ? { model: modelName(model) } : {}),
109
108
  };
110
109
  }
111
110
  /**
@@ -30,9 +30,11 @@ export declare function listChildren(ports: CourierPorts, parentID: string): Pro
30
30
  name: string;
31
31
  report: "failed" | "interrupted" | "out" | "unknown" | "deleted" | "dropped" | "held" | "released";
32
32
  };
33
- directory: string;
34
- isolated: boolean;
35
33
  created: number;
34
+ model?: string | undefined;
35
+ native?: true | undefined;
36
+ isolated: boolean;
37
+ directory: string;
36
38
  sessionID?: string | undefined;
37
39
  title?: string | undefined;
38
40
  parentID?: string | undefined;
@@ -44,9 +46,11 @@ export declare function listChildren(ports: CourierPorts, parentID: string): Pro
44
46
  pending?: readonly import("./ports.js").Pending[] | undefined;
45
47
  } | {
46
48
  group?: undefined;
47
- directory: string;
48
- isolated: boolean;
49
49
  created: number;
50
+ model?: string | undefined;
51
+ native?: true | undefined;
52
+ isolated: boolean;
53
+ directory: string;
50
54
  sessionID?: string | undefined;
51
55
  title?: string | undefined;
52
56
  parentID?: string | undefined;
@@ -62,17 +66,21 @@ export declare function listChildren(ports: CourierPorts, parentID: string): Pro
62
66
  name: string;
63
67
  report: "failed" | "interrupted" | "out" | "unknown" | "deleted" | "dropped" | "held" | "released";
64
68
  };
65
- directory: string;
66
- isolated: boolean;
67
69
  created: number;
70
+ model?: string | undefined;
71
+ native?: true | undefined;
72
+ isolated: boolean;
73
+ directory: string;
68
74
  sessionID: string;
69
75
  title: string;
70
76
  } | {
71
77
  error: string;
72
78
  group?: undefined;
73
- directory: string;
74
- isolated: boolean;
75
79
  created: number;
80
+ model?: string | undefined;
81
+ native?: true | undefined;
82
+ isolated: boolean;
83
+ directory: string;
76
84
  sessionID: string;
77
85
  title: string;
78
86
  })[]>;
@@ -82,9 +90,11 @@ export declare function described(ports: CourierPorts, entry: RosterEntry): Prom
82
90
  name: string;
83
91
  report: "failed" | "interrupted" | "out" | "unknown" | "deleted" | "dropped" | "held" | "released";
84
92
  };
85
- directory: string;
86
- isolated: boolean;
87
93
  created: number;
94
+ model?: string | undefined;
95
+ native?: true | undefined;
96
+ isolated: boolean;
97
+ directory: string;
88
98
  sessionID?: string | undefined;
89
99
  title?: string | undefined;
90
100
  parentID?: string | undefined;
@@ -96,9 +106,11 @@ export declare function described(ports: CourierPorts, entry: RosterEntry): Prom
96
106
  pending?: readonly import("./ports.js").Pending[] | undefined;
97
107
  } | {
98
108
  group?: undefined;
99
- directory: string;
100
- isolated: boolean;
101
109
  created: number;
110
+ model?: string | undefined;
111
+ native?: true | undefined;
112
+ isolated: boolean;
113
+ directory: string;
102
114
  sessionID?: string | undefined;
103
115
  title?: string | undefined;
104
116
  parentID?: string | undefined;
@@ -114,17 +126,21 @@ export declare function described(ports: CourierPorts, entry: RosterEntry): Prom
114
126
  name: string;
115
127
  report: "failed" | "interrupted" | "out" | "unknown" | "deleted" | "dropped" | "held" | "released";
116
128
  };
117
- directory: string;
118
- isolated: boolean;
119
129
  created: number;
130
+ model?: string | undefined;
131
+ native?: true | undefined;
132
+ isolated: boolean;
133
+ directory: string;
120
134
  sessionID: string;
121
135
  title: string;
122
136
  } | {
123
137
  error: string;
124
138
  group?: undefined;
125
- directory: string;
126
- isolated: boolean;
127
139
  created: number;
140
+ model?: string | undefined;
141
+ native?: true | undefined;
142
+ isolated: boolean;
143
+ directory: string;
128
144
  sessionID: string;
129
145
  title: string;
130
146
  }>;
@@ -1,5 +1,5 @@
1
1
  import { describeFailure } from "../kernel/protocol.js";
2
- import { standingOf } from "./domain.js";
2
+ import { standingOf, startedAs } from "./domain.js";
3
3
  import { current } from "./roster.js";
4
4
  import { groupStore } from "./store.js";
5
5
  /** A one-off look at a session, for check-ins; not meant to be called in a loop. */
@@ -40,7 +40,7 @@ export async function listChildren(ports, parentID) {
40
40
  /** One roster entry as courier_children shows it: what courier_status reports with its directory and group standing, or the error it gave. */
41
41
  export async function described(ports, entry) {
42
42
  const group = entry.group ? { group: { name: entry.group, report: await standingIn(ports, entry, entry.group) } } : {};
43
- const roster = { directory: entry.directory, isolated: entry.isolated, created: entry.createdAt, ...group };
43
+ const roster = { directory: entry.directory, ...startedAs(entry), created: entry.createdAt, ...group };
44
44
  try {
45
45
  return { ...(await status(ports, { sessionID: entry.sessionID })), ...roster };
46
46
  }
@@ -65,9 +65,11 @@ export declare function tree(ports: CourierPorts, rootID: string): Promise<{
65
65
  name: string;
66
66
  report: "failed" | "interrupted" | "out" | "unknown" | "deleted" | "dropped" | "held" | "released";
67
67
  };
68
- directory: string;
69
- isolated: boolean;
70
68
  created: number;
69
+ model?: string | undefined;
70
+ native?: true | undefined;
71
+ isolated: boolean;
72
+ directory: string;
71
73
  sessionID?: string | undefined;
72
74
  title?: string | undefined;
73
75
  outcome?: "failed" | "interrupted" | "succeeded" | undefined;
@@ -86,9 +88,11 @@ export declare function tree(ports: CourierPorts, rootID: string): Promise<{
86
88
  parentID: string;
87
89
  depth: number;
88
90
  group?: undefined;
89
- directory: string;
90
- isolated: boolean;
91
91
  created: number;
92
+ model?: string | undefined;
93
+ native?: true | undefined;
94
+ isolated: boolean;
95
+ directory: string;
92
96
  sessionID?: string | undefined;
93
97
  title?: string | undefined;
94
98
  outcome?: "failed" | "interrupted" | "succeeded" | undefined;
@@ -111,9 +115,11 @@ export declare function tree(ports: CourierPorts, rootID: string): Promise<{
111
115
  name: string;
112
116
  report: "failed" | "interrupted" | "out" | "unknown" | "deleted" | "dropped" | "held" | "released";
113
117
  };
114
- directory: string;
115
- isolated: boolean;
116
118
  created: number;
119
+ model?: string | undefined;
120
+ native?: true | undefined;
121
+ isolated: boolean;
122
+ directory: string;
117
123
  sessionID: string;
118
124
  title: string;
119
125
  } | {
@@ -127,9 +133,11 @@ export declare function tree(ports: CourierPorts, rootID: string): Promise<{
127
133
  depth: number;
128
134
  error: string;
129
135
  group?: undefined;
130
- directory: string;
131
- isolated: boolean;
132
136
  created: number;
137
+ model?: string | undefined;
138
+ native?: true | undefined;
139
+ isolated: boolean;
140
+ directory: string;
133
141
  sessionID: string;
134
142
  title: string;
135
143
  })[];
@@ -1,3 +1,4 @@
1
+ import { type ModelRef } from "./domain.js";
1
2
  import type { Artifacts, CleanupResult, ExecutionError, HeldReport, LeftMember, Limits, Sent } from "./ports.js";
2
3
  /** The branch an isolated child commits its work on, and names in the `branch` of its report. */
3
4
  export declare const childBranch: (sessionID: string) => string;
@@ -13,11 +14,19 @@ export declare function rolePart(depth: number | null, limits: Limits): string;
13
14
  export declare const depthRefusal: (depth: number, maxDepth: number) => string;
14
15
  export declare const childrenRefusal: (running: number, maxChildren: number) => string;
15
16
  export declare const totalRefusal: (running: number, maxTotal: number) => string;
17
+ export declare const modelFormatRefusal: (given: string) => string;
18
+ /** The model is not among those OpenCode lists; `named` says the caller named it, rather than inherited it and named a variant. */
19
+ export declare function unknownModelRefusal(model: ModelRef, known: ReadonlyArray<Pick<ModelRef, "providerID" | "id">>, named: boolean): string;
20
+ export declare function unknownVariantRefusal(model: ModelRef, variants: ReadonlyArray<string>): string;
21
+ /** A variant was named, but the model it would apply to could not be read: the named agent's, else the caller's own or OpenCode's default. */
22
+ export declare function variantWithoutModelRefusal(variant: string, agent?: string): string;
16
23
  /**
17
24
  * The body of a report: its text, then the artifacts in a fixed layout, the same for every report,
18
25
  * so a parent finds the branch, the commits, the files and the checks without reading prose.
19
26
  */
20
27
  export declare function reportBody(message: string, artifacts: Artifacts | undefined): string;
28
+ /** courier_spawn's refusal of `native: true` with `isolate: true`: OpenCode gives a session a worktree or a parent, never both. */
29
+ export declare const nativeIsolateRefusal: () => string;
21
30
  export declare function spawnText(child: {
22
31
  readonly sessionID: string;
23
32
  readonly directory: string;
@@ -26,6 +35,8 @@ export declare function spawnText(child: {
26
35
  readonly groupError?: string;
27
36
  readonly fromParent?: boolean;
28
37
  readonly uncommitted?: boolean;
38
+ /** The model it runs on, when that is not the caller's own. */
39
+ readonly model?: string;
29
40
  }): string;
30
41
  /** courier_send's result: for a message to the session that started the sender, whether it was its report. */
31
42
  export declare function sendText(sessionID: string, sent?: Pick<Sent, "status" | "report" | "held">): string;
package/dist/tree/text.js CHANGED
@@ -19,6 +19,9 @@ export function childBrief(parentID, task, depth, limits, branch) {
19
19
  "concrete ask, then end your turn and wait for its answer; never end quietly.",
20
20
  "A report with a status wakes the parent and is the only way it hears from you, so do not end without sending one. " +
21
21
  "A message without a status is progress, not your report: if you end your turn without a report, the parent is told so.",
22
+ "If you find something the other parts of the job need (an assumption of the split that is wrong, a shared file or API " +
23
+ "that behaves otherwise, something the person told you), send it to the parent at once with courier_send without a " +
24
+ "status, then carry on: it passes it on to the sessions it affects. Progress alone is not worth a message.",
22
25
  "If you need the person to decide something, use your question tool; it reaches them through the session that started you.",
23
26
  "",
24
27
  ...(branch ? [...isolationRule(branch, depth < limits.maxDepth), ""] : []),
@@ -48,7 +51,9 @@ function splitRule(parentID, depth, limits) {
48
51
  `You are at depth ${depth} of a session tree that goes at most ${limits.maxDepth} deep. Split your task or do it yourself:`,
49
52
  "- Split it when it has 2 or more independent parts, each substantial and touching separate files or areas: start one " +
50
53
  `session per part with courier_spawn, at most ${limits.maxChildren} of yours running at once and ${limits.maxTotal} in the whole tree.`,
51
- "- Do it yourself when it is small, sequential or tightly coupled. Never start exactly one session for the whole task; a core you stage before the rest is one part of it, not the whole, when 2 or more parts follow it.",
54
+ "- Do it yourself when it is small, sequential or tightly coupled. Never start exactly one session for the whole task. One session for one stage is a part, not the whole: " +
55
+ "a core you stage before the rest, when 2 or more parts follow it; a review of the parts once you have merged them; the fixing of what a review found once you have verified it. " +
56
+ "One session alone, with nothing of the task done before it by you or by 2 or more parts, and fewer than 2 parts after it, is the whole task.",
52
57
  `- Say which in one line of your reply: ${SPLIT_LINE}.`,
53
58
  "- If you split, you orchestrate: start the parts that run at once in one group, with the same group name for each, " +
54
59
  "then end your turn while they work. Do not poll with courier_status, courier_children, courier_tree or sleep: the group's " +
@@ -89,6 +94,36 @@ export const childrenRefusal = (running, maxChildren) => `Not started: ${count(r
89
94
  `(maxChildren ${maxChildren}). Do this part yourself, or end your turn and start it once one of them has reported.`;
90
95
  export const totalRefusal = (running, maxTotal) => `Not started: ${count(running, "session")} in this session tree ${live(running)}, the most allowed at once ` +
91
96
  `(maxTotal ${maxTotal}). Do this part yourself, or end your turn and try again once a report has arrived.`;
97
+ // courier_spawn's refusals of a model or a variant OpenCode does not list.
98
+ /** How many models, or providers, a refusal names before it counts the rest. */
99
+ const NAMED = 12;
100
+ const some = (items) => (items.length > NAMED ? `${items.slice(0, NAMED).join(", ")} and ${items.length - NAMED} more` : items.join(", "));
101
+ 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)}. ` +
102
+ "Leave it unset to run the session on your own model.";
103
+ /** The model is not among those OpenCode lists; `named` says the caller named it, rather than inherited it and named a variant. */
104
+ export function unknownModelRefusal(model, known, named) {
105
+ const name = `${model.providerID}/${model.id}`;
106
+ const ids = known.filter((item) => item.providerID === model.providerID).map((item) => item.id);
107
+ const providers = [...new Set(known.map((item) => item.providerID))];
108
+ let hint = "No model is listed here at all.";
109
+ if (ids.length)
110
+ hint = `The models of ${model.providerID} here are ${some(ids)}.`;
111
+ else if (providers.length)
112
+ hint = `The providers here are ${some(providers)}.`;
113
+ return named
114
+ ? `Not started: OpenCode lists no model ${name} here. ${hint} Leave model unset to run the session on your own model.`
115
+ : `Not started: the variant cannot be checked, since OpenCode lists no model ${name} here, the one the session would run on. ${hint}`;
116
+ }
117
+ export function unknownVariantRefusal(model, variants) {
118
+ const listed = variants.length ? `${variants.join(", ")} and default` : "default alone";
119
+ return (`Not started: the model ${model.providerID}/${model.id} has no variant ${JSON.stringify(model.variant)}. ` +
120
+ `Its variants are ${listed}; leave variant unset for the model's default.`);
121
+ }
122
+ /** A variant was named, but the model it would apply to could not be read: the named agent's, else the caller's own or OpenCode's default. */
123
+ export function variantWithoutModelRefusal(variant, agent) {
124
+ const why = agent ? `the agent ${agent} could not be looked up` : "neither your own model nor OpenCode's default could be read";
125
+ return `Not started: variant ${JSON.stringify(variant)} needs a model to apply to, and ${why}. Name a model as provider/model, or leave variant unset.`;
126
+ }
92
127
  /** The strings of a list given as artifacts, whatever else the model put in it. */
93
128
  const strings = (value) => (Array.isArray(value) ? value.flatMap((item) => (typeof item === "string" ? [item.trim()] : [])) : []).filter(Boolean);
94
129
  /** A list's line in the artifacts block, or none when it is empty. */
@@ -112,6 +147,9 @@ export function reportBody(message, artifacts) {
112
147
  return lines.length ? [message, "", "Artifacts:", ...lines].join("\n") : message;
113
148
  }
114
149
  // Tool results.
150
+ /** courier_spawn's refusal of `native: true` with `isolate: true`: OpenCode gives a session a worktree or a parent, never both. */
151
+ export const nativeIsolateRefusal = () => "Not started: native: true and isolate: true cannot go together. OpenCode runs a session in a worktree of its own or as a " +
152
+ "subagent of its parent, never both. Start it with isolate: true alone for a worktree of its own, or with native: true alone for a subagent in your directory.";
115
153
  export function spawnText(child) {
116
154
  const warning = (child.fromParent ? " Its worktree starts from the commit your own worktree is on." : "") +
117
155
  (child.uncommitted
@@ -124,7 +162,8 @@ export function spawnText(child) {
124
162
  "turn has ended, you get their reports in one message, which starts a new turn for you; a blocked report, a question " +
125
163
  "or a failed turn still reaches you at once."
126
164
  : "It will report back with courier_send, which starts a new turn for you.";
127
- return (`Started session ${child.sessionID} in ${child.directory}. ${reports} Once you have started every session you need, ` +
165
+ const on = child.model ? `, on ${child.model}` : "";
166
+ return (`Started session ${child.sessionID} in ${child.directory}${on}. ${reports} Once you have started every session you need, ` +
128
167
  `end your turn: reply without calling more tools. That does not drop the task; you carry on with it when the reports arrive.${warning}`);
129
168
  }
130
169
  /** courier_send's result: for a message to the session that started the sender, whether it was its report. */
@@ -9,7 +9,10 @@ export declare const SpawnInput: Schema.Struct<{
9
9
  readonly task: Schema.String;
10
10
  readonly title: Schema.optional<Schema.String>;
11
11
  readonly agent: Schema.optional<Schema.String>;
12
+ readonly model: Schema.optional<Schema.String>;
13
+ readonly variant: Schema.optional<Schema.String>;
12
14
  readonly isolate: Schema.optional<Schema.Boolean>;
15
+ readonly native: Schema.optional<Schema.Boolean>;
13
16
  readonly group: Schema.optional<Schema.String>;
14
17
  }>;
15
18
  export declare const SendInput: Schema.Struct<{
@@ -16,10 +16,26 @@ export const SpawnInput = Schema.Struct({
16
16
  task: Schema.String.annotate({ description: "What the new session should do. It is told who started it and how to report back." }),
17
17
  title: Schema.optional(Schema.String.annotate({ description: "Session title; defaults to the task's first line." })),
18
18
  agent: Schema.optional(Schema.String.annotate({ description: "Agent to run the session with; defaults to the default agent." })),
19
+ model: Schema.optional(Schema.String.annotate({
20
+ description: "The model to run the session on, as provider/model (opencode/gpt-5-nano). Leave it unset for your own model, or the " +
21
+ "agent's own if it names one. Refused when OpenCode does not list it here.",
22
+ })),
23
+ variant: Schema.optional(Schema.String.annotate({
24
+ description: "The model's variant, its reasoning effort as OpenCode names it (low, medium, high, or another the model has). Leave " +
25
+ "it unset for the chosen model's own; with a model of your own choosing, that is the model's default. Refused when " +
26
+ "the model has no such variant.",
27
+ })),
19
28
  isolate: Schema.optional(Schema.Boolean.annotate({ description: "Run the session in its own git worktree so parallel sessions don't share files. From a session that is itself " +
20
29
  "isolated, the worktree starts from your current commit, not your uncommitted changes: commit first. The session " +
21
30
  "commits on a branch named courier/<its session ID> and names it in its report; merge that branch yourself.",
22
31
  })),
32
+ native: Schema.optional(Schema.Boolean.annotate({
33
+ description: "true starts the session as your OpenCode subagent: it leaves OpenCode's session lists for your subagent tab, the " +
34
+ "person answers its permission requests and questions in the view of the nearest session above it that is not a " +
35
+ "subagent, usually the top one, and deleting your session deletes it. false starts a standalone session, whatever the " +
36
+ "plugin's nativeChildren option says; unset, that option decides. Refused with isolate: true, as a subagent runs in " +
37
+ "its parent's directory.",
38
+ })),
23
39
  group: Schema.optional(Schema.String.annotate({
24
40
  description: "A join group: start the sessions of one batch with the same name, and their reports are held and delivered " +
25
41
  "to you in one message once every one of them has reported and your turn has ended, instead of one turn per " +
@@ -72,7 +88,8 @@ export function addTreeTools(tools, ports) {
72
88
  tools.add({
73
89
  name: "courier_spawn",
74
90
  options: { codemode: false },
75
- description: "Start a new OpenCode session on a task and return immediately. It runs on your model. The session reports " +
91
+ description: "Start a new OpenCode session on a task and return immediately. It runs on your model, unless you name a model or a " +
92
+ "variant (reasoning effort) fit for the task. The session reports " +
76
93
  "back with courier_send, which wakes this session, and you are told if its turn fails or ends without a report " +
77
94
  "instead, it waits for a permission or it asks a question. Give a batch of parallel sessions one group name to " +
78
95
  "be woken once, with all their reports, instead of once per report. DO NOT poll it or call courier_status in a " +
package/dist/tui.js CHANGED
@@ -1457,6 +1457,8 @@ var Row = Schema3.Struct({
1457
1457
  report: Schema3.optionalKey(Schema3.Struct({ state: Schema3.Literals(["owed", "reported", "ended"]), status: Schema3.optionalKey(Schema3.String), progressed: Schema3.Boolean })),
1458
1458
  group: Schema3.optionalKey(Schema3.Struct({ name: Schema3.String, standing: Schema3.String })),
1459
1459
  isolated: Schema3.Boolean,
1460
+ native: Schema3.optionalKey(Schema3.Boolean),
1461
+ model: Schema3.optionalKey(Schema3.String),
1460
1462
  created: Schema3.Number,
1461
1463
  idle: Schema3.optionalKey(Schema3.Number),
1462
1464
  cost: Schema3.optionalKey(Schema3.Number),
@@ -1740,6 +1742,12 @@ function statusParts(row, now = Date.now()) {
1740
1742
  }, {
1741
1743
  text: row.isolated ? "isolated" : "",
1742
1744
  kind: "plain"
1745
+ }, {
1746
+ text: row.native ? "native" : "",
1747
+ kind: "plain"
1748
+ }, {
1749
+ text: row.model ?? "",
1750
+ kind: "plain"
1743
1751
  }];
1744
1752
  return parts.filter((part) => part.text);
1745
1753
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-courier",
3
- "version": "0.11.0",
3
+ "version": "0.12.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>",
@@ -56,14 +56,14 @@
56
56
  "test:e2e": "e2e/run.sh"
57
57
  },
58
58
  "peerDependencies": {
59
- "@opencode/plugin": "2.1.3",
59
+ "@opencode/plugin": "2.1.4",
60
60
  "effect": "4.0.1"
61
61
  },
62
62
  "devDependencies": {
63
- "@opencode/plugin": "2.1.3",
64
- "@opencode/theme": "2.1.3",
65
- "@opentui/core": "0.6.6",
66
- "@opentui/solid": "0.6.6",
63
+ "@opencode/plugin": "2.1.4",
64
+ "@opencode/theme": "2.1.4",
65
+ "@opentui/core": "0.6.8",
66
+ "@opentui/solid": "0.6.8",
67
67
  "@types/bun": "1.4.2",
68
68
  "@types/node": "26.6.4",
69
69
  "effect": "4.0.1",