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