opencode-courier 0.2.3 → 0.3.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 +124 -20
- package/dist/bounded.d.ts +2 -4
- package/dist/bounded.js +2 -4
- package/dist/cleanup.d.ts +8 -3
- package/dist/cleanup.js +24 -12
- package/dist/courier.d.ts +116 -11
- package/dist/courier.js +239 -55
- package/dist/group.d.ts +86 -0
- package/dist/group.js +247 -0
- package/dist/hub.d.ts +52 -77
- package/dist/hub.js +68 -65
- package/dist/index.d.ts +4 -8
- package/dist/index.js +27 -20
- package/dist/json.d.ts +4 -0
- package/dist/json.js +11 -0
- package/dist/later.d.ts +6 -6
- package/dist/later.js +6 -5
- package/dist/limits.d.ts +65 -0
- package/dist/limits.js +109 -0
- package/dist/notices.d.ts +125 -15
- package/dist/notices.js +304 -25
- package/dist/question/answer.js +6 -9
- package/dist/question/lifecycle.d.ts +10 -19
- package/dist/question/lifecycle.js +10 -19
- package/dist/question/pure.d.ts +3 -4
- package/dist/question/pure.js +2 -3
- package/dist/question/relay.js +13 -27
- package/dist/question/runtime.d.ts +2 -3
- package/dist/question/runtime.js +2 -3
- package/dist/relay.d.ts +2 -4
- package/dist/relay.js +2 -4
- package/dist/report.d.ts +48 -0
- package/dist/report.js +88 -0
- package/dist/roster.d.ts +29 -17
- package/dist/roster.js +57 -26
- package/dist/skill.d.ts +13 -0
- package/dist/skill.js +9 -0
- package/dist/stop.d.ts +41 -0
- package/dist/stop.js +120 -0
- package/dist/tools.d.ts +20 -0
- package/dist/tools.js +81 -12
- package/dist/tree.d.ts +102 -0
- package/dist/tree.js +58 -0
- package/dist/version.d.ts +4 -10
- package/dist/version.js +4 -10
- package/dist/watch.d.ts +55 -18
- package/dist/watch.js +219 -34
- package/dist/webhook.d.ts +10 -15
- package/dist/webhook.js +11 -20
- package/package.json +1 -1
package/dist/roster.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import { dropMember } from "./group.js";
|
|
3
|
+
import { forgetReport } from "./report.js";
|
|
2
4
|
import { scanAll, scanEntries } from "./storage.js";
|
|
3
5
|
/**
|
|
4
6
|
* Entries older than this are dropped when their parent's roster is read, and on plugin setup,
|
|
@@ -7,10 +9,8 @@ import { scanAll, scanEntries } from "./storage.js";
|
|
|
7
9
|
export const RETENTION_MS = 14 * 24 * 60 * 60_000;
|
|
8
10
|
const PREFIX = "roster/";
|
|
9
11
|
/**
|
|
10
|
-
* The reverse index: under `roster-by-child/<sessionID>`,
|
|
11
|
-
* roster entry
|
|
12
|
-
* is found by scanning `roster/`, and every entry it leads to is read from `roster/` to check that
|
|
13
|
-
* it is still there.
|
|
12
|
+
* The reverse index: a child's `ReverseEntry` under `roster-by-child/<sessionID>`, written with its
|
|
13
|
+
* roster entry; only an index, so `roster/` is scanned for entries lacking one, and confirms a hit.
|
|
14
14
|
*/
|
|
15
15
|
const BY_CHILD = "roster-by-child/";
|
|
16
16
|
export function rosterKey(parentID, sessionID) {
|
|
@@ -37,17 +37,24 @@ function isReverse(value) {
|
|
|
37
37
|
return Array.isArray(ancestors) && ancestors.length > 0 && ancestors.every((id) => typeof id === "string");
|
|
38
38
|
}
|
|
39
39
|
/**
|
|
40
|
-
* Removes a child's roster entry
|
|
41
|
-
* reverse key left behind leads nowhere, and the next load drops it.
|
|
40
|
+
* Removes a child's roster entry, its reverse key, its report state and, given its group, its membership. Only
|
|
41
|
+
* the entry's removal can fail it: a reverse key left behind leads nowhere, and the next load drops it.
|
|
42
42
|
*/
|
|
43
|
-
export async function remove(storage, parentID, sessionID) {
|
|
44
|
-
|
|
43
|
+
export async function remove(storage, parentID, sessionID, group) {
|
|
44
|
+
const [, , , dropped] = await Promise.all([
|
|
45
|
+
storage.remove(rosterKey(parentID, sessionID)),
|
|
46
|
+
storage.remove(reverseKey(sessionID)).catch(() => undefined),
|
|
47
|
+
forgetReport(storage, sessionID).catch(() => undefined),
|
|
48
|
+
group ? dropMember(storage, parentID, group, sessionID).catch(() => false) : false,
|
|
49
|
+
]);
|
|
50
|
+
return { droppedFromGroup: dropped };
|
|
45
51
|
}
|
|
46
52
|
/** Removes a child from its parent's roster; false when it was not there. */
|
|
47
53
|
export async function forget(storage, parentID, sessionID) {
|
|
48
|
-
|
|
54
|
+
const entry = (await storage.get(rosterKey(parentID, sessionID)));
|
|
55
|
+
if (entry === undefined)
|
|
49
56
|
return false;
|
|
50
|
-
await remove(storage, parentID, sessionID);
|
|
57
|
+
await remove(storage, parentID, sessionID, entry.group);
|
|
51
58
|
return true;
|
|
52
59
|
}
|
|
53
60
|
/** A parent's children, oldest first. */
|
|
@@ -61,21 +68,42 @@ export async function allEntries(storage) {
|
|
|
61
68
|
}
|
|
62
69
|
/** A session's roster entries: one for the parent that started it, none if courier_spawn did not. */
|
|
63
70
|
export async function entriesOf(storage, sessionID) {
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
71
|
+
const parentID = await indexedParent(storage, sessionID);
|
|
72
|
+
if (parentID === undefined)
|
|
66
73
|
return (await scanAll(storage, PREFIX)).filter((entry) => entry.sessionID === sessionID);
|
|
67
|
-
const entry = await storage.get(rosterKey(
|
|
74
|
+
const entry = await storage.get(rosterKey(parentID, sessionID));
|
|
68
75
|
return entry === undefined ? [] : [entry];
|
|
69
76
|
}
|
|
77
|
+
/** The session that started a session, by the reverse index alone, without scanning the roster. */
|
|
78
|
+
export async function indexedParent(storage, sessionID) {
|
|
79
|
+
const indexed = await storage.get(reverseKey(sessionID));
|
|
80
|
+
return isReverse(indexed) ? indexed.ancestors[0] : undefined;
|
|
81
|
+
}
|
|
82
|
+
/** A session's roster entry under a parent, if it is there. */
|
|
83
|
+
export async function entryUnder(storage, parentID, sessionID) {
|
|
84
|
+
return (await storage.get(rosterKey(parentID, sessionID)));
|
|
85
|
+
}
|
|
86
|
+
/** A session's roster entry, by the reverse index alone, without scanning the roster. */
|
|
87
|
+
export async function indexedEntry(storage, sessionID) {
|
|
88
|
+
const parentID = await indexedParent(storage, sessionID);
|
|
89
|
+
return parentID === undefined ? undefined : entryUnder(storage, parentID, sessionID);
|
|
90
|
+
}
|
|
70
91
|
/**
|
|
71
|
-
* The roster entries from a session up to the top, its own first
|
|
72
|
-
*
|
|
73
|
-
* did not start the session.
|
|
92
|
+
* The roster entries from a session up to the top, its own first, ending with the entry whose parent
|
|
93
|
+
* courier_spawn did not start; empty when courier_spawn did not start the session.
|
|
74
94
|
*/
|
|
75
95
|
export async function lineage(storage, sessionID) {
|
|
76
96
|
const first = await storage.get(reverseKey(sessionID));
|
|
77
97
|
if (!isReverse(first))
|
|
78
98
|
return lineageIn(await allEntries(storage), sessionID);
|
|
99
|
+
return walkIndex(storage, sessionID, first);
|
|
100
|
+
}
|
|
101
|
+
/** `lineage` by the reverse index alone: empty, without scanning the roster, when it does not name the session. */
|
|
102
|
+
export async function indexedLineage(storage, sessionID) {
|
|
103
|
+
const first = await storage.get(reverseKey(sessionID));
|
|
104
|
+
return isReverse(first) ? walkIndex(storage, sessionID, first) : [];
|
|
105
|
+
}
|
|
106
|
+
async function walkIndex(storage, sessionID, first) {
|
|
79
107
|
let indexed = first;
|
|
80
108
|
const chain = [];
|
|
81
109
|
let below = sessionID;
|
|
@@ -101,17 +129,22 @@ export async function lineage(storage, sessionID) {
|
|
|
101
129
|
}
|
|
102
130
|
/** `lineage` over roster entries already read. */
|
|
103
131
|
export function lineageIn(entries, sessionID) {
|
|
104
|
-
|
|
132
|
+
return lineageBy(bySession(entries), sessionID);
|
|
133
|
+
}
|
|
134
|
+
/** Roster entries by their session, for `lineageBy`. */
|
|
135
|
+
export function bySession(entries) {
|
|
136
|
+
return new Map(entries.map((entry) => [entry.sessionID, entry]));
|
|
137
|
+
}
|
|
138
|
+
/** `lineage` over roster entries already read and mapped by `bySession`. */
|
|
139
|
+
export function lineageBy(bySession, sessionID) {
|
|
105
140
|
const chain = [];
|
|
106
141
|
for (let entry = bySession.get(sessionID); entry && !chain.includes(entry); entry = bySession.get(entry.parentID))
|
|
107
142
|
chain.push(entry);
|
|
108
143
|
return chain;
|
|
109
144
|
}
|
|
110
145
|
/**
|
|
111
|
-
* The
|
|
112
|
-
*
|
|
113
|
-
* session was not started with courier_spawn or `callerID` is another session. `what` names
|
|
114
|
-
* what is being answered.
|
|
146
|
+
* The top of a spawned session's lineage, where its permission requests and questions go, since the
|
|
147
|
+
* person is there. Throws unless the session was spawned and `callerID` is that top; `what` names it.
|
|
115
148
|
*/
|
|
116
149
|
export async function answeringTop(storage, sessionID, callerID, what) {
|
|
117
150
|
const chain = await lineage(storage, sessionID);
|
|
@@ -128,7 +161,7 @@ export async function answeringTop(storage, sessionID, callerID, what) {
|
|
|
128
161
|
*/
|
|
129
162
|
async function dropExpired(storage, entries, now, exists) {
|
|
130
163
|
const expired = new Set(entries.filter((entry) => now - entry.createdAt > RETENTION_MS && !(entry.isolated && exists(entry.directory))));
|
|
131
|
-
await Promise.all([...expired].map((entry) => remove(storage, entry.parentID, entry.sessionID)));
|
|
164
|
+
await Promise.all([...expired].map((entry) => remove(storage, entry.parentID, entry.sessionID, entry.group)));
|
|
132
165
|
return entries.filter((entry) => !expired.has(entry));
|
|
133
166
|
}
|
|
134
167
|
/** A parent's children, oldest first, after dropping the expired ones. */
|
|
@@ -147,10 +180,8 @@ function indexes(value, entry) {
|
|
|
147
180
|
return isReverse(value) && value.ancestors[0] === entry.parentID;
|
|
148
181
|
}
|
|
149
182
|
/**
|
|
150
|
-
* Writes the reverse keys missing for the given entries, or
|
|
151
|
-
*
|
|
152
|
-
* entry an older copy removed. Keys that lead to their entry are left as they are, so running it
|
|
153
|
-
* again changes nothing.
|
|
183
|
+
* Writes the reverse keys missing or stale for the given entries, or the whole roster, and drops those
|
|
184
|
+
* whose entry is gone, as an older copy's writes and removals leave them; a rerun changes nothing.
|
|
154
185
|
*/
|
|
155
186
|
export async function backfill(storage, entries) {
|
|
156
187
|
const indexed = new Map((await scanEntries(storage, BY_CHILD)).map(({ key, value }) => [key, value]));
|
package/dist/skill.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin";
|
|
2
|
+
/** The editor OpenCode hands a skill transform. */
|
|
3
|
+
export type SkillEditor = Parameters<Parameters<Plugin.Context["skill"]["transform"]>[0]>[0];
|
|
4
|
+
/** The courier-orchestrate skill as OpenCode lists it: its id, name and description, and the text the model loads. */
|
|
5
|
+
export declare const orchestrateSkill: () => {
|
|
6
|
+
readonly id: "courier-orchestrate";
|
|
7
|
+
readonly name: "courier-orchestrate";
|
|
8
|
+
readonly description: string;
|
|
9
|
+
readonly path: "/opencode-courier/courier-orchestrate.md";
|
|
10
|
+
readonly content: string;
|
|
11
|
+
};
|
|
12
|
+
/** Adds the courier-orchestrate skill; every instance adds the same one, under the same id. */
|
|
13
|
+
export declare function addSkill(skills: SkillEditor): void;
|
package/dist/skill.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { SKILL_CONTENT, SKILL_DESCRIPTION, SKILL_ID } from "./notices.js";
|
|
2
|
+
/** A skill's path is where OpenCode would look for its files; this one has none, so a path of the plugin's own. */
|
|
3
|
+
const SKILL_PATH = `/opencode-courier/${SKILL_ID}.md`;
|
|
4
|
+
/** The courier-orchestrate skill as OpenCode lists it: its id, name and description, and the text the model loads. */
|
|
5
|
+
export const orchestrateSkill = () => ({ id: SKILL_ID, name: SKILL_ID, description: SKILL_DESCRIPTION, path: SKILL_PATH, content: SKILL_CONTENT });
|
|
6
|
+
/** Adds the courier-orchestrate skill; every instance adds the same one, under the same id. */
|
|
7
|
+
export function addSkill(skills) {
|
|
8
|
+
skills.add(orchestrateSkill());
|
|
9
|
+
}
|
package/dist/stop.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type CleanupPorts } from "./cleanup.js";
|
|
2
|
+
import { type CourierPorts } from "./courier.js";
|
|
3
|
+
import { type LaterPorts } from "./later.js";
|
|
4
|
+
import { type CleanupResult } from "./notices.js";
|
|
5
|
+
export interface StopPorts {
|
|
6
|
+
readonly courier: CourierPorts;
|
|
7
|
+
readonly later: LaterPorts;
|
|
8
|
+
readonly cleanup: CleanupPorts;
|
|
9
|
+
}
|
|
10
|
+
export interface StopInput {
|
|
11
|
+
readonly sessionID: string;
|
|
12
|
+
readonly cleanup?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** What happened to one session: its turn was interrupted, it was not running, OpenCode no longer knows it, or the interrupt failed. */
|
|
15
|
+
export interface Stopped {
|
|
16
|
+
readonly sessionID: string;
|
|
17
|
+
readonly title: string;
|
|
18
|
+
readonly depth: number;
|
|
19
|
+
readonly outcome: "interrupted" | "idle" | "gone" | "failed";
|
|
20
|
+
readonly error?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface StopResult {
|
|
23
|
+
readonly sessionID: string;
|
|
24
|
+
/** Every session stopped, deepest first; the target is the last. */
|
|
25
|
+
readonly stopped: ReadonlyArray<Stopped>;
|
|
26
|
+
/** The ids of the courier_later messages cancelled. */
|
|
27
|
+
readonly cancelled: ReadonlyArray<string>;
|
|
28
|
+
/** With `cleanup`: what became of each isolated session's worktree, deepest first. */
|
|
29
|
+
readonly cleanup?: ReadonlyArray<CleanupResult | {
|
|
30
|
+
readonly sessionID: string;
|
|
31
|
+
readonly outcome: "failed";
|
|
32
|
+
readonly error: string;
|
|
33
|
+
}>;
|
|
34
|
+
/** The session told that the target was stopped, when that is not the caller. */
|
|
35
|
+
readonly told?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Stops a session and everything under it, deepest first, and cancels the messages scheduled for them; only a session above the target may.
|
|
39
|
+
* The caller is not told; the target's parent is, unless that is the caller.
|
|
40
|
+
*/
|
|
41
|
+
export declare function stop(ports: StopPorts, callerID: string, input: StopInput): Promise<StopResult>;
|
package/dist/stop.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { cleanup } from "./cleanup.js";
|
|
2
|
+
import { describeFailure } from "./courier.js";
|
|
3
|
+
import { isNotFound } from "./json.js";
|
|
4
|
+
import { cancel, scheduledFor } from "./later.js";
|
|
5
|
+
import { envelope, stoppedNotice } from "./notices.js";
|
|
6
|
+
import { settled, stopped } from "./report.js";
|
|
7
|
+
import { lineage } from "./roster.js";
|
|
8
|
+
import { subtree } from "./tree.js";
|
|
9
|
+
/** Rounds of looking again for sessions started while others were being stopped. */
|
|
10
|
+
const ROUNDS = 3;
|
|
11
|
+
/**
|
|
12
|
+
* Stops a session and everything under it, deepest first, and cancels the messages scheduled for them; only a session above the target may.
|
|
13
|
+
* The caller is not told; the target's parent is, unless that is the caller.
|
|
14
|
+
*/
|
|
15
|
+
export async function stop(ports, callerID, input) {
|
|
16
|
+
const { storage } = ports.courier;
|
|
17
|
+
const { sessionID } = input;
|
|
18
|
+
const chain = await lineage(storage, sessionID);
|
|
19
|
+
if (!chain.length)
|
|
20
|
+
throw new Error(`${sessionID} was not started with courier_spawn, so courier_stop cannot stop it.`);
|
|
21
|
+
const target = chain[0];
|
|
22
|
+
if (!chain.some((entry) => entry.parentID === callerID))
|
|
23
|
+
throw new Error(`${callerID} is not above ${sessionID}: only a session above it, in the line of sessions that started it with courier_spawn, can stop it.`);
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
const nodes = [];
|
|
26
|
+
const results = [];
|
|
27
|
+
const cancelled = [];
|
|
28
|
+
const round = async (pending, left) => {
|
|
29
|
+
if (!pending.length || left === 0)
|
|
30
|
+
return;
|
|
31
|
+
for (const node of pending)
|
|
32
|
+
seen.add(node.entry.sessionID);
|
|
33
|
+
nodes.push(...pending);
|
|
34
|
+
// Before any interrupt, so nothing scheduled wakes a session that was just stopped.
|
|
35
|
+
await Promise.all(pending.map((node) => silence(ports, node.entry.sessionID, cancelled)));
|
|
36
|
+
results.push(...(await eachDeepestFirst(pending, (node) => interrupt(ports, node))));
|
|
37
|
+
const meanwhile = (await subtree(storage, sessionID)).nodes.filter((node) => !seen.has(node.entry.sessionID));
|
|
38
|
+
return round(meanwhile, left - 1);
|
|
39
|
+
};
|
|
40
|
+
await round([{ entry: target, depth: 0 }, ...(await subtree(storage, sessionID)).nodes], ROUNDS);
|
|
41
|
+
const told = target.parentID !== callerID && results.find((one) => one.sessionID === sessionID)?.outcome === "interrupted"
|
|
42
|
+
? await tell(ports, callerID, target)
|
|
43
|
+
: undefined;
|
|
44
|
+
return {
|
|
45
|
+
sessionID,
|
|
46
|
+
stopped: results,
|
|
47
|
+
cancelled,
|
|
48
|
+
...(input.cleanup ? { cleanup: await removeWorktrees(ports, nodes) } : {}),
|
|
49
|
+
...(told ? { told } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Runs `act` on the nodes level by level, the deepest level first and the level's nodes at once; the results in that order. */
|
|
53
|
+
function eachDeepestFirst(nodes, act) {
|
|
54
|
+
const depths = [...new Set(nodes.map((node) => node.depth))].sort((a, b) => b - a);
|
|
55
|
+
return depths.reduce((before, depth) => before.then(async (done) => [...done, ...(await Promise.all(nodes.filter((node) => node.depth === depth).map(act)))]), Promise.resolve([]));
|
|
56
|
+
}
|
|
57
|
+
/** Notes that a session was stopped, so the failure and silent-end notices stay quiet for it, and cancels its pending messages. */
|
|
58
|
+
async function silence(ports, sessionID, cancelled) {
|
|
59
|
+
const { storage, now, log } = ports.courier;
|
|
60
|
+
await stopped(storage, sessionID, now()).catch((error) => log(`courier_stop: could not note that ${sessionID} was stopped: ${String(error)}`));
|
|
61
|
+
try {
|
|
62
|
+
const pending = await scheduledFor(storage, sessionID);
|
|
63
|
+
const dropped = await Promise.all(pending.map(async (later) => ((await cancel(ports.later, later.id)) ? later.id : undefined)));
|
|
64
|
+
cancelled.push(...dropped.filter((id) => id !== undefined));
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
log(`courier_stop: could not cancel the messages scheduled for ${sessionID}: ${String(error)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function interrupt(ports, { entry, depth }) {
|
|
71
|
+
const { sessionID, title } = entry;
|
|
72
|
+
const { storage, session, now } = ports.courier;
|
|
73
|
+
try {
|
|
74
|
+
const { interrupted } = await session.interrupt({ sessionID });
|
|
75
|
+
// The event for it says so too; noted here as well, so the end is settled when this returns.
|
|
76
|
+
if (interrupted)
|
|
77
|
+
await settled(storage, sessionID, "interrupted", now()).catch(() => undefined);
|
|
78
|
+
return { sessionID, title, depth, outcome: interrupted ? "interrupted" : "idle" };
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (isNotFound(error))
|
|
82
|
+
return { sessionID, title, depth, outcome: "gone" };
|
|
83
|
+
return { sessionID, title, depth, outcome: "failed", error: describeFailure("session.interrupt", error).message };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Tells the target's parent, which is below the caller, that it was stopped; undefined when that could not be done. */
|
|
87
|
+
async function tell(ports, callerID, target) {
|
|
88
|
+
const { session, log } = ports.courier;
|
|
89
|
+
try {
|
|
90
|
+
await session.synthetic({
|
|
91
|
+
sessionID: target.parentID,
|
|
92
|
+
text: envelope(target.sessionID, stoppedNotice(target.title, callerID), { ended: "stopped" }),
|
|
93
|
+
description: `Session ${target.sessionID} was stopped`,
|
|
94
|
+
metadata: { source: "courier", from: target.sessionID, ended: "stopped" },
|
|
95
|
+
delivery: "steer",
|
|
96
|
+
});
|
|
97
|
+
return target.parentID;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
log(`courier_stop: could not tell ${target.parentID} that ${target.sessionID} was stopped: ${String(error)}`);
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Removes the isolated sessions' worktrees, deepest first, through courier_cleanup's checks; a failure is reported, not thrown. */
|
|
105
|
+
async function removeWorktrees(ports, nodes) {
|
|
106
|
+
// One at a time: git locks the repository's worktree list while it removes one.
|
|
107
|
+
const removeNext = async (queue, done) => {
|
|
108
|
+
const [next, ...rest] = queue;
|
|
109
|
+
if (!next)
|
|
110
|
+
return done;
|
|
111
|
+
const { entry } = next;
|
|
112
|
+
const result = await cleanup(ports.cleanup, entry.parentID, { sessionID: entry.sessionID }).catch((error) => ({
|
|
113
|
+
sessionID: entry.sessionID,
|
|
114
|
+
outcome: "failed",
|
|
115
|
+
error: describeFailure("courier_cleanup", error).message,
|
|
116
|
+
}));
|
|
117
|
+
return removeNext(rest, [...done, result]);
|
|
118
|
+
};
|
|
119
|
+
return removeNext(nodes.filter((node) => node.entry.isolated).sort((a, b) => b.depth - a.depth), []);
|
|
120
|
+
}
|
package/dist/tools.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode/plugin";
|
|
2
2
|
import { Schema } from "effect";
|
|
3
3
|
import { type CleanupPorts } from "./cleanup.js";
|
|
4
|
+
import { type StopPorts } from "./stop.js";
|
|
4
5
|
import { type CourierPorts } from "./courier.js";
|
|
5
6
|
import { type LaterPorts } from "./later.js";
|
|
6
7
|
import { type QuestionPorts } from "./question/index.js";
|
|
@@ -15,10 +16,21 @@ export declare const SpawnInput: Schema.Struct<{
|
|
|
15
16
|
readonly title: Schema.optional<Schema.String>;
|
|
16
17
|
readonly agent: Schema.optional<Schema.String>;
|
|
17
18
|
readonly isolate: Schema.optional<Schema.Boolean>;
|
|
19
|
+
readonly group: Schema.optional<Schema.String>;
|
|
18
20
|
}>;
|
|
19
21
|
export declare const SendInput: Schema.Struct<{
|
|
20
22
|
readonly sessionID: Schema.String;
|
|
21
23
|
readonly message: Schema.String;
|
|
24
|
+
readonly status: Schema.optional<Schema.Literals<readonly ["done", "partial", "blocked", "failed"]>>;
|
|
25
|
+
readonly artifacts: Schema.optional<Schema.Struct<{
|
|
26
|
+
readonly branch: Schema.optional<Schema.String>;
|
|
27
|
+
readonly commits: Schema.optional<Schema.$Array<Schema.String>>;
|
|
28
|
+
readonly files: Schema.optional<Schema.$Array<Schema.String>>;
|
|
29
|
+
readonly checks: Schema.optional<Schema.$Array<Schema.Struct<{
|
|
30
|
+
readonly command: Schema.String;
|
|
31
|
+
readonly result: Schema.String;
|
|
32
|
+
}>>>;
|
|
33
|
+
}>>;
|
|
22
34
|
readonly queue: Schema.optional<Schema.Boolean>;
|
|
23
35
|
}>;
|
|
24
36
|
export declare const StatusInput: Schema.Struct<{
|
|
@@ -27,6 +39,13 @@ export declare const StatusInput: Schema.Struct<{
|
|
|
27
39
|
export declare const ChildrenInput: Schema.Struct<{
|
|
28
40
|
readonly sessionID: Schema.optional<Schema.String>;
|
|
29
41
|
}>;
|
|
42
|
+
export declare const TreeInput: Schema.Struct<{
|
|
43
|
+
readonly sessionID: Schema.optional<Schema.String>;
|
|
44
|
+
}>;
|
|
45
|
+
export declare const StopInput: Schema.Struct<{
|
|
46
|
+
readonly sessionID: Schema.String;
|
|
47
|
+
readonly cleanup: Schema.optional<Schema.Boolean>;
|
|
48
|
+
}>;
|
|
30
49
|
export declare const CleanupInput: Schema.Struct<{
|
|
31
50
|
readonly sessionID: Schema.String;
|
|
32
51
|
readonly force: Schema.optional<Schema.Boolean>;
|
|
@@ -61,6 +80,7 @@ export type ToolEditor = Parameters<Parameters<Plugin.Context["tool"]["transform
|
|
|
61
80
|
export interface ToolPorts {
|
|
62
81
|
readonly courier: CourierPorts;
|
|
63
82
|
readonly cleanup: CleanupPorts;
|
|
83
|
+
readonly stop: StopPorts;
|
|
64
84
|
readonly later: LaterPorts;
|
|
65
85
|
readonly answer: AnswerPorts;
|
|
66
86
|
readonly questions: QuestionPorts;
|
package/dist/tools.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
2
|
import { cleanup } from "./cleanup.js";
|
|
3
|
+
import { stop } from "./stop.js";
|
|
4
|
+
import { tree } from "./tree.js";
|
|
3
5
|
import { describeFailure, listChildren, send, spawn, status } from "./courier.js";
|
|
4
6
|
import { cancel, schedule } from "./later.js";
|
|
5
|
-
import { answerText, cancelText, childrenText, cleanupText, laterText, REPLIES, sendText, spawnText, statusText, subscribeText, unsubscribeText, } from "./notices.js";
|
|
7
|
+
import { answerText, cancelText, childrenText, cleanupText, laterText, REPLIES, sendText, spawnText, STATUSES, statusText, stopText, subscribeText, treeText, unsubscribeText, } from "./notices.js";
|
|
6
8
|
import { answerQuestion, isQuestion } from "./question/index.js";
|
|
7
9
|
import { answer } from "./relay.js";
|
|
8
10
|
import { subscribe, unsubscribe } from "./webhook.js";
|
|
@@ -14,11 +16,33 @@ export const SpawnInput = Schema.Struct({
|
|
|
14
16
|
task: Schema.String.annotate({ description: "What the new session should do. It is told who started it and how to report back." }),
|
|
15
17
|
title: Schema.optional(Schema.String.annotate({ description: "Session title; defaults to the task's first line." })),
|
|
16
18
|
agent: Schema.optional(Schema.String.annotate({ description: "Agent to run the session with; defaults to the default agent." })),
|
|
17
|
-
isolate: Schema.optional(Schema.Boolean.annotate({ description: "Run the session in its own git worktree so parallel sessions don't share files."
|
|
19
|
+
isolate: Schema.optional(Schema.Boolean.annotate({ description: "Run the session in its own git worktree so parallel sessions don't share files. From a session that is itself " +
|
|
20
|
+
"isolated, the worktree starts from your current commit, not your uncommitted changes: commit first. The session " +
|
|
21
|
+
"commits on a branch named courier/<its session ID> and names it in its report; merge that branch yourself.",
|
|
22
|
+
})),
|
|
23
|
+
group: Schema.optional(Schema.String.annotate({
|
|
24
|
+
description: "A join group: start the sessions of one batch with the same name, and their reports are held and delivered " +
|
|
25
|
+
"to you in one message once every one of them has reported and your turn has ended, instead of one turn per " +
|
|
26
|
+
"report. A blocked report, a question, a permission request or a failed turn still reaches you at once. Letters, " +
|
|
27
|
+
"digits, dots, dashes and underscores, up to 60; the name is free again once the group has been delivered.",
|
|
28
|
+
})),
|
|
18
29
|
});
|
|
19
30
|
export const SendInput = Schema.Struct({
|
|
20
31
|
sessionID: Schema.String.annotate({ description: "The session to deliver to." }),
|
|
21
32
|
message: Schema.String.annotate({ description: "The message text." }),
|
|
33
|
+
status: Schema.optional(Schema.Literals(STATUSES).annotate({
|
|
34
|
+
description: "For a report to the session that started you: done, partial (some of the task is left), blocked (you need a " +
|
|
35
|
+
"decision from it; say what) or failed. A message without a status is progress, not your report.",
|
|
36
|
+
})),
|
|
37
|
+
artifacts: Schema.optional(Schema.Struct({
|
|
38
|
+
branch: Schema.optional(Schema.String.annotate({ description: "The branch your work is on." })),
|
|
39
|
+
commits: Schema.optional(Schema.Array(Schema.String).annotate({ description: "The commits you made, as short hashes or subjects." })),
|
|
40
|
+
files: Schema.optional(Schema.Array(Schema.String).annotate({ description: "The files you changed." })),
|
|
41
|
+
checks: Schema.optional(Schema.Array(Schema.Struct({
|
|
42
|
+
command: Schema.String.annotate({ description: "A command you ran, such as a test suite or a linter." }),
|
|
43
|
+
result: Schema.String.annotate({ description: "What it gave, in a few words." }),
|
|
44
|
+
})).annotate({ description: "The checks you ran and their results." })),
|
|
45
|
+
}).annotate({ description: "With a report: what you produced, listed in the message after your text in a fixed layout." })),
|
|
22
46
|
queue: Schema.optional(Schema.Boolean.annotate({ description: "Wait until the target's current turn ends instead of steering it now." })),
|
|
23
47
|
});
|
|
24
48
|
export const StatusInput = Schema.Struct({
|
|
@@ -27,8 +51,18 @@ export const StatusInput = Schema.Struct({
|
|
|
27
51
|
export const ChildrenInput = Schema.Struct({
|
|
28
52
|
sessionID: Schema.optional(Schema.String.annotate({ description: "The session whose children to list; defaults to this one." })),
|
|
29
53
|
});
|
|
54
|
+
export const TreeInput = Schema.Struct({
|
|
55
|
+
sessionID: Schema.optional(Schema.String.annotate({ description: "The session whose subtree to list; defaults to this one." })),
|
|
56
|
+
});
|
|
57
|
+
export const StopInput = Schema.Struct({
|
|
58
|
+
sessionID: Schema.String.annotate({ description: "The session to stop, with every session under it. It must have been started, directly or through others, from this one." }),
|
|
59
|
+
cleanup: Schema.optional(Schema.Boolean.annotate({
|
|
60
|
+
description: "Also remove the worktrees of the stopped sessions that ran in one of their own, as courier_cleanup does: " +
|
|
61
|
+
"one with uncommitted changes or commits on no branch is kept and listed.",
|
|
62
|
+
})),
|
|
63
|
+
});
|
|
30
64
|
export const CleanupInput = Schema.Struct({
|
|
31
|
-
sessionID: Schema.String.annotate({ description: "The
|
|
65
|
+
sessionID: Schema.String.annotate({ description: "The session you started to drop, with its worktree if it is isolated." }),
|
|
32
66
|
force: Schema.optional(Schema.Boolean.annotate({
|
|
33
67
|
description: "Remove it even with uncommitted changes or commits on no branch; that work is lost.",
|
|
34
68
|
})),
|
|
@@ -100,11 +134,12 @@ export function addTools(tools, ports) {
|
|
|
100
134
|
name: "courier_spawn",
|
|
101
135
|
options: { codemode: false },
|
|
102
136
|
description: "Start a new OpenCode session on a task and return immediately. It runs on your model. The session reports " +
|
|
103
|
-
"back with courier_send, which wakes this session, and you are told if its turn fails
|
|
104
|
-
"permission or it asks a question.
|
|
105
|
-
"
|
|
106
|
-
"
|
|
107
|
-
"
|
|
137
|
+
"back with courier_send, which wakes this session, and you are told if its turn fails or ends without a report " +
|
|
138
|
+
"instead, it waits for a permission or it asks a question. Give a batch of parallel sessions one group name to " +
|
|
139
|
+
"be woken once, with all their reports, instead of once per report. DO NOT poll it or call courier_status in a " +
|
|
140
|
+
"loop: once you have started the sessions you need, end your turn by replying without calling more tools; each " +
|
|
141
|
+
"report, or each group's reports, starts a new turn in which you carry on. For long tasks, also courier_later a " +
|
|
142
|
+
"check-in for yourself in case it never reports, and courier_cancel it when it does.",
|
|
108
143
|
input: SpawnInput,
|
|
109
144
|
execute: async (input, context) => {
|
|
110
145
|
const child = await spawn(ports.courier, context.sessionID, input).catch(rethrow("courier_spawn"));
|
|
@@ -115,11 +150,15 @@ export function addTools(tools, ports) {
|
|
|
115
150
|
name: "courier_send",
|
|
116
151
|
options: { codemode: false },
|
|
117
152
|
description: "Deliver a message to another OpenCode session. If that session is idle, OpenCode starts a new turn for it. " +
|
|
118
|
-
"Use it to report back to the session that started you,
|
|
153
|
+
"Use it to report back to the session that started you, with a status (done, partial, blocked or failed) and " +
|
|
154
|
+
"the artifacts you produced, or to steer a session you started. The status is carried as an attribute of the " +
|
|
155
|
+
"message; without one, a message to the session that started you is progress, not your report. If you were " +
|
|
156
|
+
"started in a group, a report with status done, partial or failed is held and delivered with the group's other " +
|
|
157
|
+
"reports; the result says so, and nothing more is needed from you.",
|
|
119
158
|
input: SendInput,
|
|
120
159
|
execute: async (input, context) => {
|
|
121
160
|
const delivered = await send(ports.courier, context.sessionID, input).catch(rethrow("courier_send"));
|
|
122
|
-
return { content: sendText(input.sessionID), metadata: delivered };
|
|
161
|
+
return { content: sendText(input.sessionID, delivered), metadata: delivered };
|
|
123
162
|
},
|
|
124
163
|
});
|
|
125
164
|
tools.add({
|
|
@@ -136,7 +175,8 @@ export function addTools(tools, ports) {
|
|
|
136
175
|
tools.add({
|
|
137
176
|
name: "courier_children",
|
|
138
177
|
options: { codemode: false },
|
|
139
|
-
description: "List the sessions this one started with courier_spawn, with each one's state and last reply,
|
|
178
|
+
description: "List the sessions this one started with courier_spawn, with each one's state and last reply, and for one " +
|
|
179
|
+
"started in a group, its group and whether its report is held, still out, or released, e.g. after a " +
|
|
140
180
|
"compaction or restart. Like courier_status, for a one-off look, not for waiting.",
|
|
141
181
|
input: ChildrenInput,
|
|
142
182
|
execute: async (input, context) => {
|
|
@@ -144,12 +184,41 @@ export function addTools(tools, ports) {
|
|
|
144
184
|
return { content: childrenText(listed), metadata: { children: listed } };
|
|
145
185
|
},
|
|
146
186
|
});
|
|
187
|
+
tools.add({
|
|
188
|
+
name: "courier_tree",
|
|
189
|
+
options: { codemode: false },
|
|
190
|
+
description: "List the whole tree of sessions under this one (or under sessionID): every session started with courier_spawn " +
|
|
191
|
+
"from it, directly or through others, each with its depth, what courier_children shows, and whether it still " +
|
|
192
|
+
"owes a report, sent progress, or ended without one. For a one-off look, not for waiting; long trees are cut at maxTotal sessions.",
|
|
193
|
+
input: TreeInput,
|
|
194
|
+
execute: async (input, context) => {
|
|
195
|
+
const result = await tree(ports.courier, input.sessionID || context.sessionID).catch(rethrow("courier_tree"));
|
|
196
|
+
return { content: treeText(result), metadata: result };
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
tools.add({
|
|
200
|
+
name: "courier_stop",
|
|
201
|
+
options: { codemode: false },
|
|
202
|
+
description: "Stop a session you started, directly or through others, and every session under it: each running turn is " +
|
|
203
|
+
"interrupted, deepest first, and the messages scheduled for them with courier_later are cancelled. Use it when a " +
|
|
204
|
+
"subtree went wrong or is no longer needed. They do not report back, and you are not told of what you stopped; a " +
|
|
205
|
+
"session between you and the target is. A stopped session can be woken again with courier_send. With " +
|
|
206
|
+
"cleanup: true their worktrees are removed too, unless they hold work that is on no branch.",
|
|
207
|
+
input: StopInput,
|
|
208
|
+
execute: async (input, context) => {
|
|
209
|
+
const result = await stop(ports.stop, context.sessionID, input).catch(rethrow("courier_stop"));
|
|
210
|
+
return { content: stopText(result), metadata: result };
|
|
211
|
+
},
|
|
212
|
+
});
|
|
147
213
|
tools.add({
|
|
148
214
|
name: "courier_cleanup",
|
|
149
215
|
options: { codemode: false },
|
|
150
216
|
description: "Remove the git worktree of a session you started with isolate: true, once you have what you need from it, " +
|
|
151
217
|
"and drop it from courier_children. A worktree with uncommitted changes or commits on no branch is kept and " +
|
|
152
|
-
"the result lists them; commit or branch what you want, or pass force: true to discard it."
|
|
218
|
+
"the result lists them; commit or branch what you want, or pass force: true to discard it. A session without " +
|
|
219
|
+
"isolate has no worktree and is only dropped from courier_children. Either way, one in a join group that has not " +
|
|
220
|
+
"reported is dropped from it, so the group's other reports reach you without it; use this for a member you no " +
|
|
221
|
+
"longer need a report from.",
|
|
153
222
|
input: CleanupInput,
|
|
154
223
|
execute: async (input, context) => {
|
|
155
224
|
const result = await cleanup(ports.cleanup, context.sessionID, input).catch(rethrow("courier_cleanup"));
|
package/dist/tree.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { type CourierPorts } from "./courier.js";
|
|
2
|
+
import { type RosterEntry, type RosterStorage } from "./roster.js";
|
|
3
|
+
/** A session below the one a subtree is read from, with how many levels below it is: a child is at depth 1. */
|
|
4
|
+
export interface Below {
|
|
5
|
+
readonly entry: RosterEntry;
|
|
6
|
+
readonly depth: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The sessions started from a session, directly or through others, read one prefix scan per parent, at most `max`
|
|
10
|
+
* (`truncated` if more), in the order a tree is drawn.
|
|
11
|
+
*/
|
|
12
|
+
export declare function subtree(storage: RosterStorage, rootID: string, max?: number): Promise<{
|
|
13
|
+
nodes: Below[];
|
|
14
|
+
truncated: boolean;
|
|
15
|
+
}>;
|
|
16
|
+
/**
|
|
17
|
+
* The subtree under a session, at most `maxTotal` sessions: what courier_children shows of each, plus its depth,
|
|
18
|
+
* its parent and its report state.
|
|
19
|
+
*/
|
|
20
|
+
export declare function tree(ports: CourierPorts, rootID: string): Promise<{
|
|
21
|
+
truncated?: number | undefined;
|
|
22
|
+
sessionID: string;
|
|
23
|
+
sessions: ({
|
|
24
|
+
report?: {
|
|
25
|
+
status?: "done" | "partial" | "blocked" | "failed" | undefined;
|
|
26
|
+
owes: boolean;
|
|
27
|
+
progressed: boolean;
|
|
28
|
+
ended: boolean;
|
|
29
|
+
} | undefined;
|
|
30
|
+
parentID: string;
|
|
31
|
+
depth: number;
|
|
32
|
+
group: {
|
|
33
|
+
name: string;
|
|
34
|
+
report: string;
|
|
35
|
+
};
|
|
36
|
+
directory: string;
|
|
37
|
+
isolated: boolean;
|
|
38
|
+
created: number;
|
|
39
|
+
sessionID?: string | undefined;
|
|
40
|
+
title?: string | undefined;
|
|
41
|
+
outcome?: "failed" | "interrupted" | "succeeded" | undefined;
|
|
42
|
+
updated?: number | undefined;
|
|
43
|
+
idle?: number | undefined;
|
|
44
|
+
lastText?: string | undefined;
|
|
45
|
+
pending?: readonly import("./courier.js").Pending[] | undefined;
|
|
46
|
+
} | {
|
|
47
|
+
report?: {
|
|
48
|
+
status?: "done" | "partial" | "blocked" | "failed" | undefined;
|
|
49
|
+
owes: boolean;
|
|
50
|
+
progressed: boolean;
|
|
51
|
+
ended: boolean;
|
|
52
|
+
} | undefined;
|
|
53
|
+
parentID: string;
|
|
54
|
+
depth: number;
|
|
55
|
+
group?: undefined;
|
|
56
|
+
directory: string;
|
|
57
|
+
isolated: boolean;
|
|
58
|
+
created: number;
|
|
59
|
+
sessionID?: string | undefined;
|
|
60
|
+
title?: string | undefined;
|
|
61
|
+
outcome?: "failed" | "interrupted" | "succeeded" | undefined;
|
|
62
|
+
updated?: number | undefined;
|
|
63
|
+
idle?: number | undefined;
|
|
64
|
+
lastText?: string | undefined;
|
|
65
|
+
pending?: readonly import("./courier.js").Pending[] | undefined;
|
|
66
|
+
} | {
|
|
67
|
+
report?: {
|
|
68
|
+
status?: "done" | "partial" | "blocked" | "failed" | undefined;
|
|
69
|
+
owes: boolean;
|
|
70
|
+
progressed: boolean;
|
|
71
|
+
ended: boolean;
|
|
72
|
+
} | undefined;
|
|
73
|
+
parentID: string;
|
|
74
|
+
depth: number;
|
|
75
|
+
error: string;
|
|
76
|
+
group: {
|
|
77
|
+
name: string;
|
|
78
|
+
report: string;
|
|
79
|
+
};
|
|
80
|
+
directory: string;
|
|
81
|
+
isolated: boolean;
|
|
82
|
+
created: number;
|
|
83
|
+
sessionID: string;
|
|
84
|
+
title: string;
|
|
85
|
+
} | {
|
|
86
|
+
report?: {
|
|
87
|
+
status?: "done" | "partial" | "blocked" | "failed" | undefined;
|
|
88
|
+
owes: boolean;
|
|
89
|
+
progressed: boolean;
|
|
90
|
+
ended: boolean;
|
|
91
|
+
} | undefined;
|
|
92
|
+
parentID: string;
|
|
93
|
+
depth: number;
|
|
94
|
+
error: string;
|
|
95
|
+
group?: undefined;
|
|
96
|
+
directory: string;
|
|
97
|
+
isolated: boolean;
|
|
98
|
+
created: number;
|
|
99
|
+
sessionID: string;
|
|
100
|
+
title: string;
|
|
101
|
+
})[];
|
|
102
|
+
}>;
|