opencode-courier 0.14.0 → 0.16.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/text.d.ts +1 -1
- package/dist/person/text.js +1 -1
- package/dist/person/tools.js +5 -3
- package/dist/scheduling/later.js +1 -1
- package/dist/scheduling/text.js +2 -2
- package/dist/scheduling/tools.js +5 -3
- package/dist/skill.js +39 -87
- package/dist/tree/context.js +17 -5
- package/dist/tree/events.d.ts +5 -0
- package/dist/tree/events.js +39 -7
- package/dist/tree/group.js +6 -3
- package/dist/tree/limits.d.ts +1 -0
- package/dist/tree/limits.js +20 -6
- package/dist/tree/nudge.d.ts +2 -0
- package/dist/tree/ports.d.ts +3 -1
- package/dist/tree/report.d.ts +3 -0
- package/dist/tree/report.js +7 -0
- package/dist/tree/spawn.d.ts +1 -0
- package/dist/tree/spawn.js +9 -9
- package/dist/tree/stop.js +18 -6
- package/dist/tree/text.d.ts +5 -2
- package/dist/tree/text.js +67 -25
- package/dist/tree/tools.js +15 -10
- package/dist/tui.js +3 -3
- package/package.json +1 -1
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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isNotFound } from "../platform/json.js";
|
|
1
|
+
import { isNotFound, millis } from "../platform/json.js";
|
|
2
2
|
import { bySession, lineageBy } from "./domain.js";
|
|
3
3
|
import { reportOf } from "./report.js";
|
|
4
4
|
import { runs } from "./running.js";
|
|
@@ -11,10 +11,13 @@ async function live(ports, sessionID) {
|
|
|
11
11
|
reportOf(childStore(ports.storage), sessionID).catch(() => undefined),
|
|
12
12
|
]);
|
|
13
13
|
if (time === null)
|
|
14
|
-
return
|
|
15
|
-
const
|
|
14
|
+
return undefined;
|
|
15
|
+
const inTurn = report?.reportedAt !== undefined && !(millis(time?.idle) >= report.reportedAt);
|
|
16
|
+
const running = time !== undefined && !report?.ended && !inTurn && (await runs(ports.session, sessionID, time));
|
|
16
17
|
// a waiting inbox item is in no transcript until delivered (api-notes)
|
|
17
|
-
|
|
18
|
+
if (running || report?.owes || (await waitsInInbox(ports, sessionID, { stalled: false }).catch(() => true)))
|
|
19
|
+
return "live";
|
|
20
|
+
return report?.blocked ? "blocked" : undefined;
|
|
18
21
|
}
|
|
19
22
|
export async function admit(ports, parentID) {
|
|
20
23
|
const gate = ports.gate;
|
|
@@ -37,7 +40,11 @@ async function check(ports, parentID) {
|
|
|
37
40
|
const chains = new Map(entries.map((entry) => [entry.sessionID, lineageBy(parents, entry.sessionID)]));
|
|
38
41
|
const tree = entries.filter((entry) => chains.get(entry.sessionID).at(-1)?.parentID === top);
|
|
39
42
|
const counting = await Promise.all(tree.map((entry) => live(ports, entry.sessionID)));
|
|
40
|
-
const active = new Set(tree.
|
|
43
|
+
const active = new Set(tree.flatMap((entry, i) => counting[i] === "live"
|
|
44
|
+
? [entry.sessionID, ...chains.get(entry.sessionID).map((above) => above.parentID)]
|
|
45
|
+
: counting[i] === "blocked"
|
|
46
|
+
? [entry.sessionID]
|
|
47
|
+
: []));
|
|
41
48
|
const counted = tree.filter((entry) => active.has(entry.sessionID));
|
|
42
49
|
const recorded = new Set(tree.map((entry) => entry.sessionID));
|
|
43
50
|
const pending = [...ports.gate.reserved].filter((held) => held.top === top && !(held.sessionID && recorded.has(held.sessionID)));
|
|
@@ -45,8 +52,15 @@ async function check(ports, parentID) {
|
|
|
45
52
|
if (mine >= maxChildren)
|
|
46
53
|
throw new Error(childrenRefusal(mine, maxChildren));
|
|
47
54
|
if (counted.length + pending.length >= maxTotal)
|
|
48
|
-
throw new Error(totalRefusal(counted.length + pending.length, maxTotal));
|
|
55
|
+
throw new Error(totalRefusal(counted.length + pending.length, maxTotal, mine));
|
|
49
56
|
const reservation = { parentID, top };
|
|
50
57
|
ports.gate.reserved.add(reservation);
|
|
51
58
|
return { depth: chain.length + 1, reservation };
|
|
52
59
|
}
|
|
60
|
+
export async function liveBelow(ports, sessionID) {
|
|
61
|
+
const entries = await childStore(ports.storage).all();
|
|
62
|
+
const parents = bySession(entries);
|
|
63
|
+
const below = [...parents.values()].filter((entry) => entry.sessionID !== sessionID && lineageBy(parents, entry.sessionID).some((above) => above.parentID === sessionID));
|
|
64
|
+
const counting = await Promise.all(below.map((entry) => live(ports, entry.sessionID)));
|
|
65
|
+
return below.filter((_, i) => counting[i] === "live");
|
|
66
|
+
}
|
package/dist/tree/nudge.d.ts
CHANGED
|
@@ -8,10 +8,12 @@ export interface Idle {
|
|
|
8
8
|
export type NudgePorts = Pick<WatchPorts, "storage" | "session" | "permissions" | "now" | "log">;
|
|
9
9
|
export declare function owedUnwaited(ports: NudgePorts, children: ChildStore, outside: Waits, sessionID: string): Promise<{
|
|
10
10
|
resumesAt?: number | undefined;
|
|
11
|
+
reportedAt?: number | undefined;
|
|
11
12
|
prompt: number;
|
|
12
13
|
owes: boolean;
|
|
13
14
|
progressed: boolean;
|
|
14
15
|
ended: boolean;
|
|
16
|
+
blocked: boolean;
|
|
15
17
|
} | undefined>;
|
|
16
18
|
export declare function nudgeSilent(ports: NudgePorts, seen: Set<string>, outside: Waits, idle: Idle): Promise<boolean>;
|
|
17
19
|
export declare function onIdle(ports: NudgePorts, seen: Set<string>, outside: Waits, idle: Idle): Promise<boolean>;
|
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,13 +2,16 @@ 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, after?: number): 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;
|
|
9
|
+
reportedAt?: number | undefined;
|
|
8
10
|
prompt: number;
|
|
9
11
|
owes: boolean;
|
|
10
12
|
progressed: boolean;
|
|
11
13
|
ended: boolean;
|
|
14
|
+
blocked: boolean;
|
|
12
15
|
} | undefined>;
|
|
13
16
|
export declare function awaited(children: Notes, sessionID: string): Promise<boolean>;
|
|
14
17
|
export {};
|
package/dist/tree/report.js
CHANGED
|
@@ -4,6 +4,11 @@ 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, after = -Infinity) {
|
|
8
|
+
const { prompt, settled } = await children.notes(sessionID, ["prompt", "settled"]);
|
|
9
|
+
const at = settled.at;
|
|
10
|
+
return prompt.at !== undefined && settled.by === "report" && settled.status !== "blocked" && since(at, prompt.at) && at > after;
|
|
11
|
+
}
|
|
7
12
|
export async function lastStatus(children, sessionID) {
|
|
8
13
|
const { status } = (await children.notes(sessionID, ["settled"])).settled;
|
|
9
14
|
return isStatus(status) ? status : undefined;
|
|
@@ -20,6 +25,8 @@ export async function reportOf(children, sessionID) {
|
|
|
20
25
|
owes: !done,
|
|
21
26
|
progressed: since(progress.at, promptAt),
|
|
22
27
|
ended: done && settled.by !== "report",
|
|
28
|
+
blocked: done && settled.by === "report" && settled.status === "blocked",
|
|
29
|
+
...(done && settled.by === "report" && settled.status !== "blocked" ? { reportedAt: settled.at } : {}),
|
|
23
30
|
...(resumesAt === undefined ? {} : { resumesAt }),
|
|
24
31
|
};
|
|
25
32
|
}
|
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/stop.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { isNotFound } from "../platform/json.js";
|
|
2
2
|
import { describeFailure, envelope } from "../kernel/protocol.js";
|
|
3
3
|
import { cleanup } from "./cleanup.js";
|
|
4
|
+
import { lastStatus, reportOf } from "./report.js";
|
|
4
5
|
import { syncStanding } from "./standing.js";
|
|
5
6
|
import { childStore } from "./store.js";
|
|
6
7
|
import { subtree } from "./subtree.js";
|
|
7
|
-
import { stoppedNotice } from "./text.js";
|
|
8
|
+
import { blockedStoppedNotice, idleStoppedNotice, stoppedNotice } from "./text.js";
|
|
8
9
|
import { forgetWakes } from "./wake.js";
|
|
9
10
|
const ROUNDS = 3;
|
|
10
11
|
export const SETTLE_MS = 2_000;
|
|
@@ -39,9 +40,7 @@ export async function stop(ports, callerID, input) {
|
|
|
39
40
|
await settle(ports, nodes, results, background, startedAt);
|
|
40
41
|
const parents = new Set(nodes.flatMap((node) => (node.subagent ? [] : [node.entry.parentID])));
|
|
41
42
|
await Promise.all([...parents].map((parentID) => syncStanding(ports.courier, parentID)));
|
|
42
|
-
const told = target.parentID
|
|
43
|
-
? await tell(ports, callerID, target)
|
|
44
|
-
: undefined;
|
|
43
|
+
const told = target.parentID === callerID ? undefined : await tellParent(ports, callerID, target, results);
|
|
45
44
|
return {
|
|
46
45
|
sessionID,
|
|
47
46
|
stopped: results,
|
|
@@ -120,12 +119,25 @@ async function told(ports, parentID, ids, since) {
|
|
|
120
119
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
121
120
|
return told(ports, parentID, ids, since);
|
|
122
121
|
}
|
|
123
|
-
async function
|
|
122
|
+
async function tellParent(ports, callerID, target, results) {
|
|
123
|
+
const outcome = results.find((one) => one.sessionID === target.sessionID)?.outcome;
|
|
124
|
+
if (outcome === "interrupted")
|
|
125
|
+
return tell(ports, target, stoppedNotice(target.title, callerID));
|
|
126
|
+
if (outcome !== "idle")
|
|
127
|
+
return undefined;
|
|
128
|
+
const children = childStore(ports.courier.storage);
|
|
129
|
+
const report = await reportOf(children, target.sessionID).catch(() => undefined);
|
|
130
|
+
if (report?.owes)
|
|
131
|
+
return tell(ports, target, idleStoppedNotice(target.title, callerID));
|
|
132
|
+
const status = report ? await lastStatus(children, target.sessionID).catch(() => undefined) : undefined;
|
|
133
|
+
return status === "blocked" ? tell(ports, target, blockedStoppedNotice(target.title, callerID)) : undefined;
|
|
134
|
+
}
|
|
135
|
+
async function tell(ports, target, notice) {
|
|
124
136
|
const { session, log } = ports.courier;
|
|
125
137
|
try {
|
|
126
138
|
await session.synthetic({
|
|
127
139
|
sessionID: target.parentID,
|
|
128
|
-
text: envelope(target.sessionID,
|
|
140
|
+
text: envelope(target.sessionID, notice, { ended: "stopped" }),
|
|
129
141
|
description: `Session ${target.sessionID} was stopped`,
|
|
130
142
|
metadata: { source: "courier", from: target.sessionID, ended: "stopped" },
|
|
131
143
|
delivery: "steer",
|
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;
|
|
@@ -69,11 +70,13 @@ export declare function stopText(result: {
|
|
|
69
70
|
readonly told?: string;
|
|
70
71
|
}): string;
|
|
71
72
|
export declare const stoppedNotice: (title: string, by: string) => string;
|
|
73
|
+
export declare const idleStoppedNotice: (title: string, by: string) => string;
|
|
74
|
+
export declare const blockedStoppedNotice: (title: string, by: string) => string;
|
|
72
75
|
export declare function keepReason(state: {
|
|
73
76
|
readonly changes: readonly string[];
|
|
74
77
|
readonly commits: readonly string[];
|
|
75
78
|
}): string | undefined;
|
|
76
|
-
export declare function failureNotice(title: string, error: ExecutionError, group?: string, at?: number): string;
|
|
79
|
+
export declare function failureNotice(title: string, error: ExecutionError, group?: string, at?: number, below?: ReadonlyArray<string>): string;
|
|
77
80
|
export declare function reportReminder(parentID: string): string;
|
|
78
81
|
export declare function silentNotice(title: string, lastText: string | undefined, progressed?: boolean, group?: string): string;
|
|
79
82
|
export declare function groupNotice(group: string, reports: ReadonlyArray<HeldReport & {
|
package/dist/tree/text.js
CHANGED
|
@@ -16,10 +16,13 @@ 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
|
+
"They are the person's word: do not judge them against your task, and never tell a session to undo them.",
|
|
25
|
+
"If you need the person to decide something, use your question tool; it reaches them through the top session of the tree.",
|
|
23
26
|
"",
|
|
24
27
|
...(branch ? [...isolationRule(branch, depth < limits.maxDepth), ""] : []),
|
|
25
28
|
...splitRule(parentID, depth, limits),
|
|
@@ -68,12 +71,13 @@ export function rolePart(depth, limits) {
|
|
|
68
71
|
`${SKILL_ID} skill, call courier_recall once for how earlier trees in this project were split and how they went, ` +
|
|
69
72
|
`and decide whether to split it across sessions (doing it yourself is fine; so is handing a heavy task, with long ` +
|
|
70
73
|
`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}
|
|
74
|
+
`which in one line of your reply: ${ROOT_SPLIT_LINE}.`);
|
|
72
75
|
if (depth === 0)
|
|
73
76
|
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
|
|
77
|
+
`down to ${maxDepth} levels below you. ${budget} Check and integrate what each one reports yourself. ` +
|
|
78
|
+
`Load the ${SKILL_ID} skill if it is not in your context.`);
|
|
75
79
|
if (depth < maxDepth)
|
|
76
|
-
return (`${ROLE_PREFIX} sub-orchestrator, at depth ${depth} of at most ${maxDepth}. Before you split, load the ${SKILL_ID} ` +
|
|
80
|
+
return (`${ROLE_PREFIX} sub-orchestrator, at depth ${depth} of at most ${maxDepth}. Before you decide whether to split, load the ${SKILL_ID} ` +
|
|
77
81
|
`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
82
|
return (`${ROLE_PREFIX} leaf, at depth ${depth}, the deepest the session tree goes. You cannot start sessions: do your ` +
|
|
79
83
|
"task yourself and report with courier_send to the session that started you.");
|
|
@@ -132,11 +136,16 @@ export function standingEntry(rows) {
|
|
|
132
136
|
}
|
|
133
137
|
export const depthRefusal = (depth, maxDepth) => `Not started: this session is at depth ${depth} of its session tree, and the tree goes at most ${maxDepth} deep ` +
|
|
134
138
|
"(maxDepth), so it cannot start sessions. Do the task yourself, and report with courier_send.";
|
|
135
|
-
const live = (n) => (n === 1 ? "is running or
|
|
139
|
+
const live = (n) => (n === 1 ? "is running, blocked or still owes a report" : "are running, blocked or still owe a report");
|
|
140
|
+
const stillCounts = (whoDrops) => `A session also counts while one it started is live, and one that is blocked, or whose turn ended without a report unless it failed or was interrupted, counts until it reports or ${whoDrops} it with courier_cleanup.`;
|
|
136
141
|
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
|
-
|
|
139
|
-
|
|
142
|
+
`(maxChildren ${maxChildren}). Do this part yourself, or answer any of them that is blocked, end your turn and start it once one of them has reported, not in a loop. ` +
|
|
143
|
+
stillCounts("you drop");
|
|
144
|
+
export const totalRefusal = (running, maxTotal, yours) => `Not started: ${count(running, "session")} in this session tree ${live(running)}, the most allowed at once (maxTotal ${maxTotal}). ` +
|
|
145
|
+
(yours === 0
|
|
146
|
+
? "Do this part yourself: none of the sessions you started counts, so only reports already held for a group can still wake you."
|
|
147
|
+
: "Do this part yourself, or answer any session you started that is blocked, end your turn and try again once one of them has reported, not in a loop. " +
|
|
148
|
+
stillCounts("its parent drops"));
|
|
140
149
|
const LISTED_MAX = 12;
|
|
141
150
|
const some = (items) => (items.length > LISTED_MAX ? `${items.slice(0, LISTED_MAX).join(", ")} and ${items.length - LISTED_MAX} more` : items.join(", "));
|
|
142
151
|
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 +193,19 @@ export const nativeIsolateRefusal = () => "Not started: native: true and isolate
|
|
|
184
193
|
export function spawnText(child) {
|
|
185
194
|
const warning = (child.fromParent ? " Its worktree starts from the commit your own worktree is on." : "") +
|
|
186
195
|
(child.uncommitted
|
|
187
|
-
? " Your
|
|
196
|
+
? " 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
197
|
: "") +
|
|
189
198
|
(child.rosterError ? ` It is not on your courier_children list: ${child.rosterError}` : "") +
|
|
190
199
|
(child.groupError ? ` It is in no group, so its report comes on its own: ${child.groupError}` : "");
|
|
200
|
+
const asks = child.belowTop
|
|
201
|
+
? " 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."
|
|
202
|
+
: "";
|
|
203
|
+
const topAsks = child.belowTop ? "" : ", and so does a question or a permission request";
|
|
191
204
|
const reports = child.group
|
|
192
205
|
? `Its report is held with group "${child.group}": once every session you started in that group has reported and your ` +
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
:
|
|
206
|
+
`turn has ended, you get their reports in one message, which starts a new turn for you; a blocked report, a failed turn ` +
|
|
207
|
+
`or an end without a report still reaches you at once${topAsks}.${asks}`
|
|
208
|
+
: `It will report back with courier_send, which starts a new turn for you.${asks}`;
|
|
196
209
|
const on = child.model ? `, on ${child.model}` : "";
|
|
197
210
|
return (`Started session ${child.sessionID} in ${child.directory}${on}. ${reports} Once you have started every session you need, ` +
|
|
198
211
|
`end your turn: reply without calling more tools. That does not drop the task; you carry on with it when the reports arrive.${warning}`);
|
|
@@ -219,9 +232,10 @@ export function sendText(sessionID, sent = {}) {
|
|
|
219
232
|
return `Delivered to ${sessionID}.`;
|
|
220
233
|
}
|
|
221
234
|
export const childrenText = (listed) => listed.length ? JSON.stringify(listed, null, 2) : "No sessions started with courier_spawn.";
|
|
235
|
+
const keptLine = (result) => `Kept the worktree ${result.directory} of ${result.sessionID}: it has ${result.reason}.`;
|
|
222
236
|
export function cleanupText(result) {
|
|
223
237
|
if (result.outcome === "kept")
|
|
224
|
-
return
|
|
238
|
+
return `${keptLine(result)} ${result.sessionID} is not dropped yet: commit what you want to keep to a branch, then call courier_cleanup again, or call it now with force: true to discard it. Either call drops ${result.sessionID}.`;
|
|
225
239
|
const done = {
|
|
226
240
|
removed: `Removed the worktree ${result.directory} of ${result.sessionID}.`,
|
|
227
241
|
gone: `The worktree ${result.directory} of ${result.sessionID} was already gone; dropped it from courier_children.`,
|
|
@@ -240,14 +254,18 @@ export function treeText(tree) {
|
|
|
240
254
|
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
255
|
return JSON.stringify(tree, null, 2) + note;
|
|
242
256
|
}
|
|
257
|
+
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 " +
|
|
258
|
+
"counts toward maxChildren and maxTotal until the session that started it drops it with courier_cleanup.";
|
|
243
259
|
export function stopText(result) {
|
|
244
260
|
const interrupted = result.stopped.filter((one) => one.outcome === "interrupted").length;
|
|
245
261
|
const failed = result.stopped.filter((one) => one.outcome === "failed");
|
|
262
|
+
const idle = result.stopped.some((one) => one.outcome === "idle" && !one.subagent);
|
|
246
263
|
return [
|
|
247
264
|
`Stopped ${result.sessionID} and ${count(result.stopped.length - 1, "session")} under it, deepest first: ${interrupted} had a turn running and was interrupted, ` +
|
|
248
265
|
`${result.stopped.filter((one) => one.outcome === "idle" || one.outcome === "gone").length} did not run.`,
|
|
249
266
|
...result.stopped.map((one) => `- ${one.sessionID}${one.subagent ? " (subagent)" : ""}: ${one.outcome}${one.error ? " (" + one.error + ")" : ""}`),
|
|
250
267
|
...(failed.length ? ["Those that failed may still run: try courier_stop again."] : []),
|
|
268
|
+
...(idle ? [IDLE_STOPPED] : []),
|
|
251
269
|
result.cancelled.length
|
|
252
270
|
? `Cancelled ${count(result.cancelled.length, "scheduled message")}: ${result.cancelled.join(", ")}.`
|
|
253
271
|
: "No scheduled messages were pending for them.",
|
|
@@ -255,18 +273,33 @@ export function stopText(result) {
|
|
|
255
273
|
...(result.cleanup ? worktreeLines(result.cleanup) : []),
|
|
256
274
|
].join("\n");
|
|
257
275
|
}
|
|
258
|
-
const FORCE_HINT = " Commit or branch what you want to keep, or call courier_cleanup again with force: true to discard it.";
|
|
259
276
|
function worktreeLines(cleanup) {
|
|
260
277
|
if (!cleanup.length)
|
|
261
278
|
return ["None of them ran in a worktree of its own."];
|
|
262
|
-
return cleanup.map((one) =>
|
|
263
|
-
|
|
264
|
-
|
|
279
|
+
return cleanup.map((one) => {
|
|
280
|
+
if (one.outcome === "failed")
|
|
281
|
+
return `Worktree of ${one.sessionID}: not removed (${one.error}).`;
|
|
282
|
+
if (one.outcome === "kept")
|
|
283
|
+
return `${keptLine(one)} Kept; courier_cleanup with force: true discards it.`;
|
|
284
|
+
return cleanupText(one);
|
|
285
|
+
});
|
|
265
286
|
}
|
|
266
287
|
export const stoppedNotice = (title, by) => [
|
|
267
288
|
`This session, "${title}", which you started with courier_spawn, was stopped by ${by}, a session above you, together with the sessions under it.`,
|
|
268
289
|
"It was not a failure and it will not report back. Carry on without it, or start a replacement if you still need what it was doing.",
|
|
269
290
|
].join("\n");
|
|
291
|
+
const HOLDS_GROUP = "holds back its join group if it is in one";
|
|
292
|
+
export const idleStoppedNotice = (title, by) => [
|
|
293
|
+
`This session, "${title}", which you started with courier_spawn, was stopped by ${by}, a session above you, together with the sessions under it.`,
|
|
294
|
+
`It was not running and still owed you a report, so it still counts toward maxChildren and maxTotal, and ${HOLDS_GROUP}. ` +
|
|
295
|
+
"Drop it with courier_cleanup if you no longer need it, or message it with courier_send to have it carry on.",
|
|
296
|
+
].join("\n");
|
|
297
|
+
export const blockedStoppedNotice = (title, by) => [
|
|
298
|
+
`This session, "${title}", which you started with courier_spawn, was stopped by ${by}, a session above you, together with the sessions under it.`,
|
|
299
|
+
"It was not running: it had sent you status blocked and was waiting on your answer. Do not send it that answer, since the work it asked about was stopped above you. " +
|
|
300
|
+
`It still ${HOLDS_GROUP}. ` +
|
|
301
|
+
"Drop it with courier_cleanup if you no longer need it, or message it with courier_send if you still need it to carry on.",
|
|
302
|
+
].join("\n");
|
|
270
303
|
export function keepReason(state) {
|
|
271
304
|
const reasons = [
|
|
272
305
|
state.changes.length ? `${count(state.changes.length, "uncommitted change")} (${preview(state.changes)})` : "",
|
|
@@ -297,17 +330,24 @@ function waitLine(delay, at) {
|
|
|
297
330
|
const until = at === undefined ? "" : `, until about ${clock(at + delay, at)}`;
|
|
298
331
|
return `The provider asked to wait about ${wait(delay)} before trying again${until}.`;
|
|
299
332
|
}
|
|
300
|
-
|
|
333
|
+
function belowLine(below) {
|
|
334
|
+
const titles = below.map((title) => `"${title}"`).join(", ");
|
|
335
|
+
return (`It has ${count(below.length, "session")} of its own still running (${titles}), whose reports will wake it, and it still counts ` +
|
|
336
|
+
"toward your limits: message it rather than replace it, or stop it with courier_stop first, which stops the sessions under it too.");
|
|
337
|
+
}
|
|
338
|
+
export function failureNotice(title, error, group, at, below = []) {
|
|
301
339
|
const status = error.status === undefined ? "" : `, status ${error.status}`;
|
|
302
340
|
const delay = retryDelay(error);
|
|
341
|
+
const replace = below.length ? "" : " start a replacement,";
|
|
303
342
|
return [
|
|
304
343
|
`This session, "${title}", which you started with courier_spawn, failed: ${error.message} (${error.type}${status}).`,
|
|
305
344
|
...(delay === undefined ? [] : [waitLine(delay, at)]),
|
|
306
|
-
"Its turn ended without finishing, so it will not report back on its own.",
|
|
345
|
+
below.length ? "Its turn ended without finishing." : "Its turn ended without finishing, so it will not report back on its own.",
|
|
307
346
|
delay === undefined
|
|
308
|
-
?
|
|
309
|
-
:
|
|
347
|
+
? `Message it with courier_send to have it try again,${replace} or carry on without it.`
|
|
348
|
+
: `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
349
|
"courier_later can remind you when the wait is over.",
|
|
350
|
+
...(below.length ? [belowLine(below)] : []),
|
|
311
351
|
...(group
|
|
312
352
|
? [
|
|
313
353
|
`It is a member of group "${group}", which no longer waits for it: its other members' reports reach you once they have ` +
|
|
@@ -334,8 +374,10 @@ export function silentNotice(title, lastText, progressed = false, group) {
|
|
|
334
374
|
: []),
|
|
335
375
|
...(reply ? ["Its last reply:", defuse(clipText(reply, QUOTED_REPLY_MAX))] : ["It ended without a reply."]),
|
|
336
376
|
"",
|
|
337
|
-
|
|
338
|
-
|
|
377
|
+
reply
|
|
378
|
+
? "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."
|
|
379
|
+
: "Decide what it needs: message it with courier_send to have it carry on; or start a replacement.",
|
|
380
|
+
"Until it reports, it counts toward your limits on the sessions you run at once, or until you drop it with courier_cleanup.",
|
|
339
381
|
...(group
|
|
340
382
|
? [
|
|
341
383
|
`It is a member of group "${group}", whose other reports are held until it reports: have it report, as above, so the group ` +
|
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, a
|
|
39
|
+
"report. A blocked report, a failed turn or an end without a report still reaches you at once, and at the top of " +
|
|
40
|
+
"the tree so do a question and a permission request (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, and one for the ungrouped sessions, in case the reports never " +
|
|
94
|
+
"come, and courier_cancel each when its last report arrives: 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"));
|
|
@@ -142,7 +146,7 @@ export function addTreeTools(tools, ports) {
|
|
|
142
146
|
description: "Stop a session you started, directly or through others, and every session under it, subagents included: each " +
|
|
143
147
|
"running turn is interrupted, deepest first, and the messages scheduled for them with courier_later are cancelled. Use it when a " +
|
|
144
148
|
"subtree went wrong or is no longer needed. They do not report back, and you are not told of what you stopped; a " +
|
|
145
|
-
"session between you and the target is. A stopped session can be woken again with courier_send. With " +
|
|
149
|
+
"session between you and the target is, when the target was running, still owed it a report or was waiting on its answer to a blocked report. A stopped session can be woken again with courier_send. With " +
|
|
146
150
|
"cleanup: true their worktrees are removed too, unless they hold work that is on no branch.",
|
|
147
151
|
input: StopInput,
|
|
148
152
|
execute: async (input, context) => {
|
|
@@ -155,8 +159,9 @@ export function addTreeTools(tools, ports) {
|
|
|
155
159
|
options: { codemode: false },
|
|
156
160
|
description: "Remove the git worktree of a session you started with isolate: true, once you have what you need from it, " +
|
|
157
161
|
"and drop it from courier_children. A worktree with uncommitted changes or commits on no branch is kept and " +
|
|
158
|
-
"the result lists them; commit
|
|
159
|
-
"isolate has no worktree and is only dropped from
|
|
162
|
+
"the result lists them, the session staying listed; commit what you want to a branch and call again, or pass " +
|
|
163
|
+
"force: true to discard it. A session without isolate has no worktree and is only dropped from " +
|
|
164
|
+
"courier_children. Once dropped, one in a join group that has not " +
|
|
160
165
|
"reported is dropped from it, so the group's other reports reach you without it; use this for a member you no " +
|
|
161
166
|
"longer need a report from. It never deletes the session itself, nor the sessions it started.",
|
|
162
167
|
input: CleanupInput,
|
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
|
|
@@ -542,7 +542,7 @@ var personRpc = Rpc2.define({
|
|
|
542
542
|
});
|
|
543
543
|
|
|
544
544
|
// src/person/text.ts
|
|
545
|
-
var REJECTED = "Refused by the session
|
|
545
|
+
var REJECTED = "Refused by the person or the top session of the tree. Do without it, or report back why you cannot.";
|
|
546
546
|
|
|
547
547
|
// src/person/tui.tsx
|
|
548
548
|
var QUESTION_FORM = "question";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-courier",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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>",
|