opencode-courier 0.13.6 → 0.13.8
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 +10 -8
- package/dist/index.js +6 -1
- package/dist/skill.d.ts +2 -2
- package/dist/skill.js +98 -111
- package/dist/tree/events.js +7 -19
- package/dist/tree/index.d.ts +1 -0
- package/dist/tree/index.js +1 -0
- package/dist/tree/nudge.d.ts +28 -0
- package/dist/tree/nudge.js +75 -0
- package/dist/tree/ports.d.ts +8 -1
- package/dist/tree/store.d.ts +1 -0
- package/dist/tree/store.js +12 -2
- package/dist/tree/text.d.ts +2 -0
- package/dist/tree/text.js +10 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ A narrated tour of the terminal UI: the tree panel, the asks inbox, failing chil
|
|
|
23
23
|
## Quickstart
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
npm install -g @opencode/cli@2.2.
|
|
26
|
+
npm install -g @opencode/cli@2.2.5 # OpenCode V2, the version courier is tested on
|
|
27
27
|
opencode plugin add opencode-courier
|
|
28
28
|
opencode
|
|
29
29
|
```
|
|
@@ -58,19 +58,21 @@ or stuck, the child calls `courier_send` with a status (`done`, `partial`, `bloc
|
|
|
58
58
|
The message lands in the parent's inbox and starts a new turn if the parent is idle.
|
|
59
59
|
|
|
60
60
|
Children started with the same `group` report together, in one message. A child that fails (a
|
|
61
|
-
rate or quota limit with when it lifts)
|
|
62
|
-
|
|
61
|
+
rate or quota limit with when it lifts) is reported to its parent. One about to end its turn
|
|
62
|
+
without reporting is reminded once, in the same turn, and its parent is told only if it still does
|
|
63
|
+
not report. A message that does not wake its session is sent once more, then shown as `stalled`. On
|
|
63
64
|
OpenCode 2.2.0 a parent's system prompt carries one line per child with its state, kept current,
|
|
64
65
|
so it need not look. Children, held reports and scheduled messages survive a restart. The `courier-orchestrate` skill, listed in every session, tells a model when to
|
|
65
|
-
split a task and how to check and merge the parts
|
|
66
|
+
split a task and how to check and merge the parts; how to plan and review them is left to
|
|
67
|
+
your own skills. Every detail: [the reference](docs/reference.md).
|
|
66
68
|
|
|
67
69
|
## Install
|
|
68
70
|
|
|
69
71
|
Requires OpenCode V2: the `opencode` command from `@opencode/cli`. Each release is tested against
|
|
70
|
-
one OpenCode version; this one is tested on **OpenCode 2.2.
|
|
72
|
+
one OpenCode version; this one is tested on **OpenCode 2.2.5** (check with `opencode --version`).
|
|
71
73
|
|
|
72
74
|
```bash
|
|
73
|
-
npm install -g @opencode/cli@2.2.
|
|
75
|
+
npm install -g @opencode/cli@2.2.5
|
|
74
76
|
opencode plugin add opencode-courier
|
|
75
77
|
```
|
|
76
78
|
|
|
@@ -83,13 +85,13 @@ name, without a version: OpenCode offers updates only for an entry without an ex
|
|
|
83
85
|
<!-- compat-table:start rows=3: generated by script/compat-table.ts from the release tags -->
|
|
84
86
|
| opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
|
|
85
87
|
|---|---|
|
|
88
|
+
| 0.13.7, 0.13.8 | 2.2.5 |
|
|
86
89
|
| 0.13.6 | 2.2.4 |
|
|
87
90
|
| 0.13.5 | 2.2.3 |
|
|
88
|
-
| 0.13.2, 0.13.3, 0.13.4 | 2.2.2 |
|
|
89
91
|
<!-- compat-table:end -->
|
|
90
92
|
|
|
91
93
|
Every release: [the full table](docs/reference.md#the-opencode-version). The server part loads on
|
|
92
|
-
any V2 host from 2.0.4 on. The terminal part is tested on OpenCode 2.2.
|
|
94
|
+
any V2 host from 2.0.4 on. The terminal part is tested on OpenCode 2.2.5 only.
|
|
93
95
|
|
|
94
96
|
## Options
|
|
95
97
|
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { addSchedulingTools, cancelScheduled, dueJob, isScheduled } from "./sche
|
|
|
10
10
|
import { addPersonTools, asksPermission, asksQuestion, formsShown, joinRelay, noticeCutOff, pendingOf, pendingQuestions, personEvents, portsAt, registerPersonRpc, relayQuestions, } from "./person/index.js";
|
|
11
11
|
import { addMemoryTools, lessonsOf, memoryEvents, NOTE_TOOL, readMemory, RECALL_TOOL, registerMemoryRpc, sweepRecords } from "./memory/index.js";
|
|
12
12
|
import { addSkill } from "./skill.js";
|
|
13
|
-
import { addTreeTools, deliverReleased, wakeMissed, dirtyOf, headOf, inspectWorktree, pruneExpired, readLimits, readNativeChildren, registerTreeRpc, shapeContext, together, treeEvents, waitsOn, } from "./tree/index.js";
|
|
13
|
+
import { addTreeTools, deliverReleased, wakeMissed, dirtyOf, headOf, inspectWorktree, onIdle, pruneExpired, readLimits, readNativeChildren, registerTreeRpc, shapeContext, together, treeEvents, waitsOn, } from "./tree/index.js";
|
|
14
14
|
import { addWebhookTools, joinReceiver, readConfig, receiving, subscribedSince, } from "./webhook/index.js";
|
|
15
15
|
/** The spawns under way in the process, outside the hub, so spawns in other locations, copies and hub versions count them. */
|
|
16
16
|
const gate = sharedState("spawning", () => ({ reserved: new Set(), turn: Promise.resolve() }));
|
|
@@ -171,6 +171,11 @@ export const courier = () => Plugin.define({
|
|
|
171
171
|
log: later.log,
|
|
172
172
|
nudge,
|
|
173
173
|
};
|
|
174
|
+
// A spawned session about to go idle owing a report is reminded once, in the same execution. The hook is
|
|
175
|
+
// experimental, and a host before 2.2.4 lacks it: the rest loads all the same, and its parents are told as before.
|
|
176
|
+
await Promise.resolve()
|
|
177
|
+
.then(() => ctx.session.hook("experimental.idle", (event) => onIdle(watchPorts, watchState.seen, outside, event).then(() => undefined)))
|
|
178
|
+
.catch((error) => log(`courier idle: not registered, a child that ends without a report is not reminded: ${String(error)}`));
|
|
174
179
|
// The hub's subscriptions to OpenCode's events, one and a standby, start with this copy's modules.
|
|
175
180
|
watchFromHub(hub, eventModules);
|
|
176
181
|
const member = {
|
package/dist/skill.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ import type { Plugin } from "@opencode/plugin";
|
|
|
2
2
|
/** Why a model loads the skill, listed with every session's available skills. */
|
|
3
3
|
export declare const SKILL_DESCRIPTION: string;
|
|
4
4
|
/**
|
|
5
|
-
* The skill's text
|
|
6
|
-
*
|
|
5
|
+
* The skill's text: how the tools behave, not how to plan or review the parts, which is left to the person's own skills.
|
|
6
|
+
* It names no limits, which the role part and the brief give; it repeats the brief's split rule, since the root has no brief.
|
|
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,129 +1,116 @@
|
|
|
1
1
|
import { ONE_PART_LINE, SKILL_ID, SPLIT_LINE } from "./kernel/protocol.js";
|
|
2
|
-
// The courier-orchestrate skill: the
|
|
2
|
+
// The courier-orchestrate skill: the text 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 = "
|
|
5
|
-
"
|
|
6
|
-
"
|
|
4
|
+
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 " +
|
|
5
|
+
"independent, substantial parts touching separate files or areas. Starting the parts, waiting without polling, " +
|
|
6
|
+
"relaying questions and permissions, reading reports, merging and reporting upward.";
|
|
7
7
|
/**
|
|
8
|
-
* The skill's text
|
|
9
|
-
*
|
|
8
|
+
* The skill's text: how the tools behave, not how to plan or review the parts, which is left to the person's own skills.
|
|
9
|
+
* It names no limits, which the role part and the brief give; it repeats the brief's split rule, since the root has no brief.
|
|
10
10
|
*/
|
|
11
11
|
export const SKILL_CONTENT = [
|
|
12
|
-
"How
|
|
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.",
|
|
12
|
+
"# How opencode-courier's session tools work",
|
|
16
13
|
"",
|
|
17
|
-
"
|
|
18
|
-
|
|
14
|
+
"Your brief, or the opencode-courier role line in your system prompt, names your depth in the session tree and its " +
|
|
15
|
+
"limits. If courier_spawn is not among your tools, you cannot split: do the task yourself, and report upward as your " +
|
|
16
|
+
"brief says if a session started you.",
|
|
17
|
+
"",
|
|
18
|
+
"This skill says how the tools behave. How to plan the parts, stage a tightly coupled core or review the merged result " +
|
|
19
|
+
"is a playbook's: if a skill for that is listed to you, load it too.",
|
|
20
|
+
"",
|
|
21
|
+
"## 1. Decide: split or do",
|
|
22
|
+
"",
|
|
23
|
+
"- At the root, first call courier_recall once, before you decide and never in a loop: it lists how earlier trees in " +
|
|
19
24
|
"this project were split and how each part went. Below the root, read none: the records describe whole trees, which " +
|
|
20
25
|
"the root weighed before it split.",
|
|
21
|
-
"
|
|
26
|
+
"- Split when the task has 2 or more independent, substantial parts touching separate files or areas; do it yourself " +
|
|
22
27
|
"when it is small, sequential or tightly coupled. Several whole jobs in one prompt, such as 4 tickets, are that many " +
|
|
23
|
-
"parts: each session runs "
|
|
24
|
-
|
|
25
|
-
" - What you read and write yourself stays in your context for every later turn: 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
|
+
"parts: each session runs this skill for its job, so the tree below you repeats its shape once per job.",
|
|
29
|
+
"- Only the root, the session nobody started, may start exactly one session for the whole task, and only when the " +
|
|
28
30
|
"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
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"Leave model and variant unset, so a part runs on your own model, unless the part plainly calls for another: a " +
|
|
72
|
-
"cheaper model or a lower variant for a small, well-specified part (a rename, a lookup), a stronger model or a higher " +
|
|
73
|
-
"variant for a design question or a hard bug. " +
|
|
74
|
-
"native: true for a part nobody needs to open on its own: it reads, checks or looks something up, reports once and is " +
|
|
75
|
-
"done; leave it off for a part the person may want to open, or that should outlive you. " +
|
|
76
|
-
"For long work, courier_later a check-in for yourself, and courier_cancel it when the reports arrive.",
|
|
77
|
-
"4. End your turn: reply without calling more tools, and do not poll with courier_children, courier_tree " +
|
|
78
|
-
"or sleep. The group's reports, a blocked report, a message without a status, a question, a permission request, a " +
|
|
79
|
-
"failed turn or a silent end each start a new turn for you; so does a message from the person or from the session " +
|
|
80
|
-
"that started you.",
|
|
81
|
-
" Answering from below: follow the notice. For a permission request, propose once with courier_answer, or reject with " +
|
|
82
|
-
"the reason when the person has told you to refuse it or it plainly goes against what they asked; the person decides, " +
|
|
83
|
-
"in OpenCode's prompt in your session. For a question, ask the person with your question tool, with the questions the " +
|
|
84
|
-
"notice gives; their answer goes on by itself. When the notice says OpenCode already shows it to the person, call " +
|
|
85
|
-
"nothing unless they give you their answer. Never answer for them.",
|
|
86
|
-
" Passing a message on: when it changes what other parts need (an assumption of the split that is wrong, a shared file " +
|
|
87
|
-
"or API that behaves otherwise, a decision of the person's), pass it on at once with courier_send, only to the sessions " +
|
|
88
|
-
"it affects, before they go further, then end your turn again; if it breaks the split, courier_stop the parts it breaks " +
|
|
89
|
-
"and plan them again, or send them a corrected task. Send it up too, without a status, when the other parts of the " +
|
|
90
|
-
"session that started you need it. Progress that changes nothing for the others goes to no one. A session's account " +
|
|
91
|
-
"of what the person told it is the person's word: pass it on to the parts it affects, do not judge it against the " +
|
|
31
|
+
"integrates that session's report itself. Every other session never starts exactly one session for the whole task: " +
|
|
32
|
+
"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 " +
|
|
33
|
+
"the whole, as your brief says: a core you stage before the rest, when 2 or more parts follow it, or a review of " +
|
|
34
|
+
"the parts once you have merged them.",
|
|
35
|
+
`- Say which in one line of your reply, before you start: ${SPLIT_LINE}; the root has a third form, ${ONE_PART_LINE}.`,
|
|
36
|
+
"",
|
|
37
|
+
"## 2. Start the parts",
|
|
38
|
+
"",
|
|
39
|
+
"- One courier_spawn per part that runs at once, all with the same group name, so that you are woken once with every " +
|
|
40
|
+
"report. No two parts edit the same files; isolate: true for each part that edits files in parallel with another. " +
|
|
41
|
+
"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 " +
|
|
42
|
+
"group of their own once the first group has reported.",
|
|
43
|
+
"- Write each task so the session can do it without you, since it sees none of your conversation: what to produce " +
|
|
44
|
+
"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 " +
|
|
45
|
+
"the skills it should load for its part, from those listed to you: it sees the same list, not your choice; one " +
|
|
46
|
+
"permission rule for skill covers their loads, as one covers the checks. Say whether it may split its part further, " +
|
|
47
|
+
"and when the part is a whole job that delivers itself (step 5), since an isolated session's brief otherwise says " +
|
|
48
|
+
"its branch is for you to merge.",
|
|
49
|
+
"- Leave model and variant unset, so a part runs on your own model, unless the part plainly calls for another: a " +
|
|
50
|
+
"cheaper one or a lower variant for a small, well-specified part, a stronger one or a higher variant for a design " +
|
|
51
|
+
"question or a hard bug. native: true for a part that only reads, checks or looks something up and reports once; " +
|
|
52
|
+
"leave it off for a part the person may want to open, or that should outlive you.",
|
|
53
|
+
"- A part that depends on another starts once that one is done: merged into your checkout, or, when it was " +
|
|
54
|
+
"delivered (step 5), started with isolate: true and told to merge the delivering session's courier/<session> " +
|
|
55
|
+
"branch first.",
|
|
56
|
+
"",
|
|
57
|
+
"## 3. End your turn",
|
|
58
|
+
"",
|
|
59
|
+
"Reply without calling more tools, and do not poll with courier_children, courier_tree or sleep. The group's reports, " +
|
|
60
|
+
"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 " +
|
|
61
|
+
"for you. For long work, courier_later a check-in for yourself, and courier_cancel it when the reports arrive.",
|
|
62
|
+
"",
|
|
63
|
+
"- Answering from below: follow the notice. A permission request: propose once with courier_answer, or reject with " +
|
|
64
|
+
"the reason when the person has told you to refuse it or it plainly goes against what they asked; the person " +
|
|
65
|
+
"decides in OpenCode's prompt. A question: ask the person with your question tool, with the questions the notice " +
|
|
66
|
+
"gives; their answer goes on by itself. When the notice says OpenCode already shows it to the person, call nothing " +
|
|
67
|
+
"unless they give you their answer. Never answer for them.",
|
|
68
|
+
"- Passing a message on: when it changes what other parts need (a wrong assumption of the split, a shared file or " +
|
|
69
|
+
"API that behaves otherwise, a decision of the person's), courier_send it at once, only to the sessions it affects, " +
|
|
70
|
+
"then end your turn again; if it breaks the split, courier_stop those parts and plan them again, or send them a " +
|
|
71
|
+
"corrected task. Send it up too, without a status, when your parent's other parts need it. Progress goes to no one. " +
|
|
72
|
+
"A session's account of what the person told it is the person's word: pass it on, do not judge it against the " +
|
|
92
73
|
"original task, and never tell a session to undo what the person asked.",
|
|
93
|
-
"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
"
|
|
102
|
-
"
|
|
103
|
-
"
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
"
|
|
108
|
-
|
|
109
|
-
"
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
"
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
"
|
|
120
|
-
"
|
|
74
|
+
"",
|
|
75
|
+
"## 4. Read each report, and verify first-hand",
|
|
76
|
+
"",
|
|
77
|
+
"- done: verify it. partial: what is left and why; have the session finish it with a courier_send, or do it " +
|
|
78
|
+
"yourself. blocked: make the decision it asks for and send it with courier_send; if only the person can make it, " +
|
|
79
|
+
"ask them with your question tool and pass the answer on. failed: read the error; message the session to try " +
|
|
80
|
+
"again, start a replacement, or carry on without it.",
|
|
81
|
+
"- Verify first-hand: open the files, run the checks. Never hand the checking to another session. Your own permission " +
|
|
82
|
+
"prompts are relayed nowhere, and each one stalls the whole tree, as each request below you waits on the person: run " +
|
|
83
|
+
"the checks so that one permission rule covers them, through the project's scripts rather than a tool's own path.",
|
|
84
|
+
"",
|
|
85
|
+
"## 5. Integrate",
|
|
86
|
+
"",
|
|
87
|
+
"Merge each reported branch into your own checkout (git merge courier/<session>), resolve conflicts yourself, run the " +
|
|
88
|
+
"checks once more, then courier_cleanup the isolated sessions you are done with. When a part is a whole job with a " +
|
|
89
|
+
"deliverable of its own, its session delivers it and reports where, still naming its courier/<session> branch when " +
|
|
90
|
+
"it is isolated; you verify each delivery and merge nothing of it into your checkout.",
|
|
91
|
+
"",
|
|
92
|
+
"## 6. Report upward",
|
|
93
|
+
"",
|
|
94
|
+
"- If a session started you: one courier_send to it, with one status for the whole (done only when every part is " +
|
|
95
|
+
"verified and integrated, or verified and delivered; partial, blocked or failed otherwise) and the artifacts.",
|
|
96
|
+
"- At the top, before you reply to the person: if the next tree in this project should know something about running " +
|
|
97
|
+
"one like this (how it was split, what a part got wrong and what caught it, what a brief should have said, what the " +
|
|
98
|
+
"person decided), one courier_note with that one line; none when there is nothing. Name the job it comes from and " +
|
|
99
|
+
"the reason, so a lesson for one kind of job is not taken for every job; the tool refuses a line over its limit " +
|
|
100
|
+
"rather than clipping it, so write it shorter. A rule that holds every time goes to AGENTS.md or a skill, through " +
|
|
101
|
+
"the person, not into a note.",
|
|
102
|
+
"",
|
|
103
|
+
"## When a part goes wrong",
|
|
104
|
+
"",
|
|
105
|
+
'- A failed turn (failed="...") or a silent end (ended="without-report", quoting the session\'s last reply): message ' +
|
|
106
|
+
"the session with courier_send to carry on, to report, or to try again; or start a replacement. Until it reports, " +
|
|
107
|
+
"it counts toward the limits.",
|
|
121
108
|
"- A refusal from courier_spawn naming maxDepth, maxChildren or maxTotal: do that part yourself, or end your turn and " +
|
|
122
109
|
"start it once a report has arrived. Do not retry it in a loop.",
|
|
123
110
|
"- Lost track, after a compaction or a restart: one courier_tree shows every session under you with its depth, its " +
|
|
124
111
|
"parent and whether it still owes a report; one look, not a loop.",
|
|
125
|
-
"- A subtree that went wrong or is no longer needed: courier_stop its top session; everything under it is stopped
|
|
126
|
-
"and nothing in it reports back. cleanup: true removes their worktrees.",
|
|
112
|
+
"- A subtree that went wrong or is no longer needed: courier_stop its top session; everything under it is stopped " +
|
|
113
|
+
"too, and nothing in it reports back. cleanup: true removes their worktrees.",
|
|
127
114
|
].join("\n");
|
|
128
115
|
/** A skill's path is where OpenCode would look for its files; this one has none, so a path of the plugin's own. */
|
|
129
116
|
const SKILL_PATH = `/opencode-courier/${SKILL_ID}.md`;
|
package/dist/tree/events.js
CHANGED
|
@@ -3,7 +3,8 @@ 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 {
|
|
6
|
+
import { owedUnwaited } from "./nudge.js";
|
|
7
|
+
import { stoppedSincePrompt } from "./report.js";
|
|
7
8
|
import { syncStanding } from "./standing.js";
|
|
8
9
|
import { childStore, groupStore } from "./store.js";
|
|
9
10
|
import { lastReply } from "./status.js";
|
|
@@ -71,6 +72,9 @@ export async function notePrompt(ports, event, inbox) {
|
|
|
71
72
|
const parentID = await children.indexedParent(sessionID);
|
|
72
73
|
if (parentID === undefined)
|
|
73
74
|
return false;
|
|
75
|
+
// The reminder to report is not a prompt: it asks for the report the last one owes.
|
|
76
|
+
if (inboxID !== undefined && (await children.nudgeOf(sessionID)).inboxID === inboxID)
|
|
77
|
+
return false;
|
|
74
78
|
await children.prompted(sessionID, event.created ?? ports.now());
|
|
75
79
|
await syncStanding(ports, parentID);
|
|
76
80
|
return true;
|
|
@@ -122,20 +126,6 @@ export const waitsOn = (...suppliers) => async (ports, sessionID, since) => {
|
|
|
122
126
|
};
|
|
123
127
|
/** A `Waits` asking `suppliers` at once, as cheap as each other: it waits when one does, and fails when one fails. */
|
|
124
128
|
export const together = (...suppliers) => async (ports, sessionID, since) => (await Promise.all(suppliers.map((supplier) => supplier(ports, sessionID, since)))).includes(true);
|
|
125
|
-
/**
|
|
126
|
-
* Whether a session prompted at `prompt` waits: on an untold report of a session it started, a complete group of
|
|
127
|
-
* its about to be delivered, or what `outside` says it waits on. Cheapest first.
|
|
128
|
-
*/
|
|
129
|
-
async function waits(ports, outside, sessionID, prompt) {
|
|
130
|
-
const children = childStore(ports.storage);
|
|
131
|
-
const started = await children.children(sessionID);
|
|
132
|
-
if ((await Promise.all(started.map((entry) => awaited(children, entry.sessionID)))).includes(true))
|
|
133
|
-
return true;
|
|
134
|
-
// A group with a member still out is waited on through that member, above; one whose every out member was told of is not.
|
|
135
|
-
if (started.some((entry) => entry.group) && (await hasCompleteGroup(groupStore(ports.storage), sessionID)))
|
|
136
|
-
return true;
|
|
137
|
-
return outside(ports, sessionID, prompt);
|
|
138
|
-
}
|
|
139
129
|
/**
|
|
140
130
|
* Tells the parent of a spawned session whose turn ended without reporting to it since its last
|
|
141
131
|
* prompt, unless it waits; finds the session by the reverse index alone. Returns the parents told.
|
|
@@ -147,10 +137,8 @@ export async function reportSilent(ports, seen, event, outside) {
|
|
|
147
137
|
// Claimed after the lookup, so other sessions' turns do not crowd `seen`, and before anything is told.
|
|
148
138
|
if (!entry || !claim(seen, event.id))
|
|
149
139
|
return [];
|
|
150
|
-
const report = await
|
|
151
|
-
if (!report
|
|
152
|
-
return [];
|
|
153
|
-
if (await stoppedSincePrompt(children, sessionID).catch(() => false))
|
|
140
|
+
const report = await owedUnwaited(ports, children, outside, sessionID);
|
|
141
|
+
if (!report)
|
|
154
142
|
return [];
|
|
155
143
|
// The notice goes out without the reply rather than not at all.
|
|
156
144
|
const lastText = await ports.session.context({ sessionID }).then(lastReply, () => undefined);
|
package/dist/tree/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { shapeContext } from "./context.js";
|
|
|
3
3
|
export { endsIdle, lineageIn, nativeRootOf, readLimits, readNativeChildren } from "./domain.js";
|
|
4
4
|
export { together, treeEvents, waitsOn } from "./events.js";
|
|
5
5
|
export { deliverReleased } from "./group.js";
|
|
6
|
+
export { onIdle } from "./nudge.js";
|
|
6
7
|
export { contextOf } from "./panel.js";
|
|
7
8
|
export type { CleanupPorts, CourierPorts, Entries, Entry, Instructions, ModelWindow, Pending, PendingPrompt, Role, RosterEntry, RosterStorage, ShownForm, SpawnGate, Waits, WatchPorts, WatchState, } from "./ports.js";
|
|
8
9
|
export { allEntries, answeringTop, childrenOf, indexedLineage, lineage, pruneExpired, reportState, RETENTION_MS, type ReportState } from "./roster.js";
|
package/dist/tree/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { shapeContext } from "./context.js";
|
|
|
5
5
|
export { endsIdle, lineageIn, nativeRootOf, readLimits, readNativeChildren } from "./domain.js";
|
|
6
6
|
export { together, treeEvents, waitsOn } from "./events.js";
|
|
7
7
|
export { deliverReleased } from "./group.js";
|
|
8
|
+
export { onIdle } from "./nudge.js";
|
|
8
9
|
export { contextOf } from "./panel.js";
|
|
9
10
|
export { allEntries, answeringTop, childrenOf, indexedLineage, lineage, pruneExpired, reportState, RETENTION_MS } from "./roster.js";
|
|
10
11
|
export { runs, turnIn, turnOf } from "./running.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ChildStore, Waits, WatchPorts } from "./ports.js";
|
|
2
|
+
/** OpenCode's `experimental.idle` hook event: a session about to go idle, and the background work still running for it or below it. */
|
|
3
|
+
export interface Idle {
|
|
4
|
+
readonly sessionID: string;
|
|
5
|
+
readonly background: ReadonlyArray<{
|
|
6
|
+
readonly sessionID: string;
|
|
7
|
+
}>;
|
|
8
|
+
}
|
|
9
|
+
/** The ports the reminder is sent through: the watcher's. */
|
|
10
|
+
export type NudgePorts = Pick<WatchPorts, "storage" | "session" | "permissions" | "now" | "log">;
|
|
11
|
+
/**
|
|
12
|
+
* The report a spawned session owes since its last prompt, unless it waits or courier_stop stopped it since: what the
|
|
13
|
+
* silent-turn notice tells the parent of, and the reminder reminds the session of.
|
|
14
|
+
*/
|
|
15
|
+
export declare function owedUnwaited(ports: NudgePorts, children: ChildStore, outside: Waits, sessionID: string): Promise<{
|
|
16
|
+
resumesAt?: number | undefined;
|
|
17
|
+
prompt: number;
|
|
18
|
+
owes: boolean;
|
|
19
|
+
progressed: boolean;
|
|
20
|
+
ended: boolean;
|
|
21
|
+
} | undefined>;
|
|
22
|
+
/**
|
|
23
|
+
* Reminds a spawned session about to go idle owing a report it does not wait on, once per prompt, so it reports in the
|
|
24
|
+
* same execution; one with background work of its own is woken again by it. Returns whether it was reminded.
|
|
25
|
+
*/
|
|
26
|
+
export declare function nudgeSilent(ports: NudgePorts, seen: Set<string>, outside: Waits, idle: Idle): Promise<boolean>;
|
|
27
|
+
/** `nudgeSilent` as the hook runs it: a hook that fails fails the execution, so a failure is logged, never thrown. */
|
|
28
|
+
export declare function onIdle(ports: NudgePorts, seen: Set<string>, outside: Waits, idle: Idle): Promise<boolean>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { envelope } from "../kernel/protocol.js";
|
|
2
|
+
import { claim } from "../platform/bounded.js";
|
|
3
|
+
import { hasCompleteGroup } from "./group.js";
|
|
4
|
+
import { awaited, reportOf, stoppedSincePrompt } from "./report.js";
|
|
5
|
+
import { childStore, groupStore } from "./store.js";
|
|
6
|
+
import { reportReminder } from "./text.js";
|
|
7
|
+
/**
|
|
8
|
+
* Whether a session prompted at `prompt` waits: on an untold report of a session it started, a complete group of
|
|
9
|
+
* its about to be delivered, or what `outside` says it waits on. Cheapest first.
|
|
10
|
+
*/
|
|
11
|
+
async function waits(ports, outside, sessionID, prompt) {
|
|
12
|
+
const children = childStore(ports.storage);
|
|
13
|
+
const started = await children.children(sessionID);
|
|
14
|
+
if ((await Promise.all(started.map((entry) => awaited(children, entry.sessionID)))).includes(true))
|
|
15
|
+
return true;
|
|
16
|
+
// A group with a member still out is waited on through that member, above; one whose every out member was told of is not.
|
|
17
|
+
if (started.some((entry) => entry.group) && (await hasCompleteGroup(groupStore(ports.storage), sessionID)))
|
|
18
|
+
return true;
|
|
19
|
+
return outside(ports, sessionID, prompt);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The report a spawned session owes since its last prompt, unless it waits or courier_stop stopped it since: what the
|
|
23
|
+
* silent-turn notice tells the parent of, and the reminder reminds the session of.
|
|
24
|
+
*/
|
|
25
|
+
export async function owedUnwaited(ports, children, outside, sessionID) {
|
|
26
|
+
const report = await reportOf(children, sessionID);
|
|
27
|
+
if (!report?.owes || (await waits(ports, outside, sessionID, report.prompt)))
|
|
28
|
+
return undefined;
|
|
29
|
+
if (await stoppedSincePrompt(children, sessionID).catch(() => false))
|
|
30
|
+
return undefined;
|
|
31
|
+
return report;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Reminds a spawned session about to go idle owing a report it does not wait on, once per prompt, so it reports in the
|
|
35
|
+
* same execution; one with background work of its own is woken again by it. Returns whether it was reminded.
|
|
36
|
+
*/
|
|
37
|
+
export async function nudgeSilent(ports, seen, outside, idle) {
|
|
38
|
+
const { sessionID } = idle;
|
|
39
|
+
if (idle.background.some((work) => work.sessionID === sessionID))
|
|
40
|
+
return false;
|
|
41
|
+
const children = childStore(ports.storage);
|
|
42
|
+
const parentID = await children.indexedParent(sessionID);
|
|
43
|
+
if (parentID === undefined)
|
|
44
|
+
return false;
|
|
45
|
+
const report = await owedUnwaited(ports, children, outside, sessionID);
|
|
46
|
+
if (!report)
|
|
47
|
+
return false;
|
|
48
|
+
const { at: nudgedAt } = await children.nudgeOf(sessionID);
|
|
49
|
+
if ((nudgedAt !== undefined && nudgedAt >= report.prompt) || !claim(seen, `nudge/${sessionID}/${report.prompt}`))
|
|
50
|
+
return false;
|
|
51
|
+
const at = Math.max(ports.now(), report.prompt);
|
|
52
|
+
// Noted before it is sent, so a second hook never sends another, and with its id after, before the turn takes it.
|
|
53
|
+
await children.nudged(sessionID, at);
|
|
54
|
+
const { id } = await ports.session.synthetic({
|
|
55
|
+
sessionID,
|
|
56
|
+
text: envelope("courier", reportReminder(parentID), { reminder: "report" }),
|
|
57
|
+
description: "Reminded to report",
|
|
58
|
+
metadata: { source: "courier", reminder: "report", resume: false },
|
|
59
|
+
delivery: "steer",
|
|
60
|
+
// The hook's own execution takes it: no wake, and the missed-wake watch leaves it be.
|
|
61
|
+
resume: false,
|
|
62
|
+
});
|
|
63
|
+
await children.nudged(sessionID, at, id);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
/** `nudgeSilent` as the hook runs it: a hook that fails fails the execution, so a failure is logged, never thrown. */
|
|
67
|
+
export async function onIdle(ports, seen, outside, idle) {
|
|
68
|
+
try {
|
|
69
|
+
return await nudgeSilent(ports, seen, outside, idle);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
ports.log(`courier idle: could not remind ${idle.sessionID} to report: ${String(error)}`);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
package/dist/tree/ports.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ 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. */
|
|
13
13
|
export type Settled = "report" | "failed" | "interrupted";
|
|
14
14
|
/** The notes kept of a spawned session's report state; docs/maintainers/architecture.md has each. */
|
|
15
|
-
export type ReportField = "prompt" | "settled" | "progress" | "told" | "stopped";
|
|
15
|
+
export type ReportField = "prompt" | "settled" | "progress" | "told" | "stopped" | "nudged";
|
|
16
16
|
/** A report note as read: when it was made, and for `settled` how the wait ended, the report's status or a failure's `resumesAt`, as stored. */
|
|
17
17
|
export interface ReportNote {
|
|
18
18
|
readonly at: number | undefined;
|
|
@@ -75,6 +75,13 @@ export interface ChildStore {
|
|
|
75
75
|
untold(sessionID: string, at: number): Promise<void>;
|
|
76
76
|
/** Notes that courier_stop stopped a spawned session at `at`. */
|
|
77
77
|
stopped(sessionID: string, at: number): Promise<void>;
|
|
78
|
+
/** Notes that a spawned session was reminded at `at` to report, with the reminder's inbox id once it is admitted. */
|
|
79
|
+
nudged(sessionID: string, at: number, inboxID?: string): Promise<void>;
|
|
80
|
+
/** When a spawned session was last reminded to report, and the reminder's inbox id. */
|
|
81
|
+
nudgeOf(sessionID: string): Promise<{
|
|
82
|
+
readonly at: number | undefined;
|
|
83
|
+
readonly inboxID?: string;
|
|
84
|
+
}>;
|
|
78
85
|
/** A `settled` or `progress` note as stored, for `restore` to put back. */
|
|
79
86
|
peek(sessionID: string, field: "settled" | "progress"): Promise<Stored | undefined>;
|
|
80
87
|
/** Puts back a note `peek` read; one that was not there is removed. */
|
package/dist/tree/store.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare const settledKey: (sessionID: string) => string;
|
|
|
14
14
|
export declare const progressKey: (sessionID: string) => string;
|
|
15
15
|
export declare const toldKey: (sessionID: string) => string;
|
|
16
16
|
export declare const stoppedKey: (sessionID: string) => string;
|
|
17
|
+
export declare const nudgedKey: (sessionID: string) => string;
|
|
17
18
|
export declare const memberKey: (parentID: string, group: string, sessionID: string) => string;
|
|
18
19
|
/** The key prefix of a parent's groups, and of one group's members. */
|
|
19
20
|
export declare const groupsPrefix: (parentID: string) => string;
|
package/dist/tree/store.js
CHANGED
|
@@ -17,8 +17,8 @@ export function reverseKey(sessionID) {
|
|
|
17
17
|
return `${BY_CHILD}${sessionID}`;
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
-
* Whether a spawned session has reported since its last prompt, under `report/<sessionID>/`: `prompt`, `settled`
|
|
21
|
-
*
|
|
20
|
+
* Whether a spawned session has reported since its last prompt, under `report/<sessionID>/`: `prompt`, `settled`, `progress`,
|
|
21
|
+
* `told`, `stopped` and `nudged` (the reminder to report); docs/maintainers/architecture.md has each.
|
|
22
22
|
*/
|
|
23
23
|
const REPORT_PREFIX = "report/";
|
|
24
24
|
export const promptKey = (sessionID) => `${REPORT_PREFIX}${sessionID}/prompt`;
|
|
@@ -26,12 +26,14 @@ export const settledKey = (sessionID) => `${REPORT_PREFIX}${sessionID}/settled`;
|
|
|
26
26
|
export const progressKey = (sessionID) => `${REPORT_PREFIX}${sessionID}/progress`;
|
|
27
27
|
export const toldKey = (sessionID) => `${REPORT_PREFIX}${sessionID}/told`;
|
|
28
28
|
export const stoppedKey = (sessionID) => `${REPORT_PREFIX}${sessionID}/stopped`;
|
|
29
|
+
export const nudgedKey = (sessionID) => `${REPORT_PREFIX}${sessionID}/nudged`;
|
|
29
30
|
const REPORT_KEYS = {
|
|
30
31
|
prompt: promptKey,
|
|
31
32
|
settled: settledKey,
|
|
32
33
|
progress: progressKey,
|
|
33
34
|
told: toldKey,
|
|
34
35
|
stopped: stoppedKey,
|
|
36
|
+
nudged: nudgedKey,
|
|
35
37
|
};
|
|
36
38
|
/**
|
|
37
39
|
* Join groups: each member of a parent's group under `group/<parentID>/<name>/<sessionID>`, with its
|
|
@@ -195,6 +197,14 @@ export function childStore(storage) {
|
|
|
195
197
|
async stopped(sessionID, at) {
|
|
196
198
|
await storage.set(stoppedKey(sessionID), { at });
|
|
197
199
|
},
|
|
200
|
+
async nudged(sessionID, at, inboxID) {
|
|
201
|
+
await storage.set(nudgedKey(sessionID), { at, ...(inboxID === undefined ? {} : { inboxID }) });
|
|
202
|
+
},
|
|
203
|
+
async nudgeOf(sessionID) {
|
|
204
|
+
const value = await storage.get(nudgedKey(sessionID));
|
|
205
|
+
const inboxID = str(obj(value).inboxID);
|
|
206
|
+
return { at: timeOf(value), ...(inboxID === undefined ? {} : { inboxID }) };
|
|
207
|
+
},
|
|
198
208
|
async peek(sessionID, field) {
|
|
199
209
|
const value = await storage.get(REPORT_KEYS[field](sessionID));
|
|
200
210
|
return value;
|
package/dist/tree/text.d.ts
CHANGED
|
@@ -108,6 +108,8 @@ export declare function keepReason(state: {
|
|
|
108
108
|
* retry time (`retryAfterMs`) counts from. Message the child before that and it fails again, so the notice says to wait.
|
|
109
109
|
*/
|
|
110
110
|
export declare function failureNotice(title: string, error: ExecutionError, group?: string, at?: number): string;
|
|
111
|
+
/** The one reminder a spawned session gets as it is about to end its turn owing its parent a report; it carries on in the same turn. */
|
|
112
|
+
export declare function reportReminder(parentID: string): string;
|
|
111
113
|
/**
|
|
112
114
|
* What the parent is told when a session it started ends its turn without a report to it;
|
|
113
115
|
* `progressed` when it messaged the parent without a status since its last prompt.
|
package/dist/tree/text.js
CHANGED
|
@@ -75,7 +75,7 @@ export function rolePart(depth, limits) {
|
|
|
75
75
|
const { maxDepth, maxChildren, maxTotal } = limits;
|
|
76
76
|
const budget = `At most ${maxChildren} of the sessions you start run at once, and ${maxTotal} in the whole tree.`;
|
|
77
77
|
if (depth === null)
|
|
78
|
-
return (`${ROLE_PREFIX} root, no sessions started yet. Before a task
|
|
78
|
+
return (`${ROLE_PREFIX} root, no sessions started yet. Before a task with 2 or more independent, substantial parts touching separate files or areas, load the ` +
|
|
79
79
|
`${SKILL_ID} skill, call courier_recall once for how earlier trees in this project were split and how they went, ` +
|
|
80
80
|
`and decide whether to split it across sessions (doing it yourself is fine; so is handing a heavy task, with long ` +
|
|
81
81
|
`reading, many edits or long runs, whole to one session to keep your context, but never a small one), and say ` +
|
|
@@ -364,6 +364,15 @@ export function failureNotice(title, error, group, at) {
|
|
|
364
364
|
: []),
|
|
365
365
|
].join("\n");
|
|
366
366
|
}
|
|
367
|
+
/** The one reminder a spawned session gets as it is about to end its turn owing its parent a report; it carries on in the same turn. */
|
|
368
|
+
export function reportReminder(parentID) {
|
|
369
|
+
return [
|
|
370
|
+
`You are about to end your turn without reporting to session ${parentID}, which started you.`,
|
|
371
|
+
`If you are finished, send your report now: call courier_send with sessionID "${parentID}", a short account of what you did and ` +
|
|
372
|
+
`status ${STATUS_LIST}. If work is left, carry on and report when you finish.`,
|
|
373
|
+
"This reminder comes once: if you end your turn without a report, the session that started you is told.",
|
|
374
|
+
].join("\n");
|
|
375
|
+
}
|
|
367
376
|
/** Longest last reply a notice that a session ended its turn without reporting quotes. */
|
|
368
377
|
const MAX_REPLY = 2000;
|
|
369
378
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-courier",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.8",
|
|
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,12 +56,12 @@
|
|
|
56
56
|
"test:e2e": "e2e/run.sh"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@opencode/plugin": "2.2.
|
|
59
|
+
"@opencode/plugin": "2.2.5",
|
|
60
60
|
"effect": "4.0.6"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@opencode/plugin": "2.2.
|
|
64
|
-
"@opencode/theme": "2.2.
|
|
63
|
+
"@opencode/plugin": "2.2.5",
|
|
64
|
+
"@opencode/theme": "2.2.5",
|
|
65
65
|
"@opentui/core": "0.8.0",
|
|
66
66
|
"@opentui/solid": "0.8.0",
|
|
67
67
|
"@types/bun": "1.4.2",
|