opencode-courier 0.10.0 → 0.10.2
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 +11 -8
- package/dist/memory/domain.d.ts +4 -1
- package/dist/memory/events.js +7 -5
- package/dist/memory/record.js +33 -14
- package/dist/scheduling/later.d.ts +2 -2
- package/dist/tree/domain.d.ts +29 -5
- package/dist/tree/domain.js +23 -4
- package/dist/tree/events.d.ts +7 -2
- package/dist/tree/events.js +19 -5
- package/dist/tree/group.js +2 -2
- package/dist/tree/index.d.ts +3 -2
- package/dist/tree/index.js +3 -2
- package/dist/tree/limits.js +10 -7
- package/dist/tree/panel.d.ts +1 -1
- package/dist/tree/panel.js +4 -6
- package/dist/tree/ports.d.ts +1 -1
- package/dist/tree/running.d.ts +18 -0
- package/dist/tree/running.js +58 -0
- package/dist/tree/send.js +7 -2
- package/dist/tree/status.d.ts +5 -0
- package/dist/tree/status.js +1 -0
- package/dist/tree/subtree.d.ts +6 -1
- package/dist/tree/subtree.js +6 -3
- package/dist/tree/summary.js +3 -4
- package/dist/tree/tui.d.ts +4 -1
- package/dist/tree/wake.d.ts +21 -3
- package/dist/tree/wake.js +51 -18
- package/dist/tui.js +47 -47
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ The edge cases on the live terminal UI, narrated ([what it shows](#demos)).
|
|
|
21
21
|
## Quickstart
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
npm install -g @opencode/cli@2.
|
|
24
|
+
npm install -g @opencode/cli@2.1.2 # OpenCode V2, at the version courier is tested on
|
|
25
25
|
opencode plugin add opencode-courier # by name, so OpenCode offers new releases
|
|
26
26
|
opencode # in any folder
|
|
27
27
|
```
|
|
@@ -42,7 +42,7 @@ sometimes stall on a request; if nothing moves for a few minutes, send the promp
|
|
|
42
42
|
|
|
43
43
|
Courier is built on OpenCode V2's plugin API (`@opencode/plugin`), not the V1 one
|
|
44
44
|
(`@opencode-ai/plugin`). Each release is pinned to one V2 version and tested against it end to end;
|
|
45
|
-
the current one is tested on **OpenCode 2.
|
|
45
|
+
the current one is tested on **OpenCode 2.1.2**. CI also runs the live suite on the newest OpenCode
|
|
46
46
|
release, on `main` and nightly. Older releases: [every release](docs/reference.md#the-opencode-version).
|
|
47
47
|
|
|
48
48
|
## Courier or the built-in subagent tool?
|
|
@@ -108,20 +108,20 @@ version than the pinned one writes one line to the server log
|
|
|
108
108
|
<!-- compat-table:start rows=3: generated by script/compat-table.ts from the release tags -->
|
|
109
109
|
| opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
|
|
110
110
|
|---|---|
|
|
111
|
+
| 0.10.2 | 2.1.2 |
|
|
112
|
+
| 0.10.1 | 2.1.0 |
|
|
111
113
|
| 0.7.3, 0.8.0, 0.9.0, 0.10.0 | 2.0.32 |
|
|
112
|
-
| 0.7.2 | 2.0.31 |
|
|
113
|
-
| 0.7.1 | 2.0.30 |
|
|
114
114
|
<!-- compat-table:end -->
|
|
115
115
|
|
|
116
116
|
Older releases and their versions: [every release](docs/reference.md#the-opencode-version).
|
|
117
117
|
|
|
118
118
|
The [terminal part](#in-the-terminal) relies on the host to provide its UI modules and is tested on
|
|
119
|
-
OpenCode 2.
|
|
119
|
+
OpenCode 2.1.2 only; the server part works on older V2 hosts from 2.0.4 on.
|
|
120
120
|
|
|
121
121
|
Check yours with `opencode --version`, and install the matching CLI with:
|
|
122
122
|
|
|
123
123
|
```bash
|
|
124
|
-
npm install -g @opencode/cli@2.
|
|
124
|
+
npm install -g @opencode/cli@2.1.2
|
|
125
125
|
```
|
|
126
126
|
|
|
127
127
|
### The plugin
|
|
@@ -175,7 +175,7 @@ From a local clone, `bun install && npm run build`, then list its `dist` **direc
|
|
|
175
175
|
|---|---|
|
|
176
176
|
| `courier_spawn` | Starts a session on the parent's model with the task and a brief, and returns at once; `isolate: true` gives it its own git worktree, `group` holds its report with its siblings'. |
|
|
177
177
|
| `courier_send` | Messages a session, waking it if idle; from a child to its parent, a `status` and `artifacts` make it the child's [report](docs/reference.md#a-childs-report). |
|
|
178
|
-
| `courier_status` | One look at a session: outcome, idle time, last reply and what it waits on. For check-ins, not for waiting. |
|
|
178
|
+
| `courier_status` | One look at a session: outcome, idle time, when it was archived, last reply and what it waits on. For check-ins, not for waiting. |
|
|
179
179
|
| `courier_children` | Lists the sessions this one (or a given one) started, with their state, worktree and join group. |
|
|
180
180
|
| `courier_tree` | Lists the whole tree under a session, depth first, with each one's depth and report state. |
|
|
181
181
|
| `courier_stop` | Interrupts a session below you and everything under it, and cancels their scheduled messages; `cleanup: true` removes their worktrees too. |
|
|
@@ -239,7 +239,10 @@ options in the plugin's `options`, in the global config:
|
|
|
239
239
|
| `maxTotal` | `20` | How many spawned sessions may be live at once in one tree. |
|
|
240
240
|
|
|
241
241
|
`courier_spawn` refuses a spawn past a limit, naming it and saying what to do instead. A session
|
|
242
|
-
counts while it is live: its turn runs, it owes its parent a report,
|
|
242
|
+
counts while it is live: its turn runs, it owes its parent a report, a message the plugin sent to
|
|
243
|
+
wake it waits undelivered in its inbox (from the moment `courier_send` returns, so a `courier_spawn` in the same step counts it),
|
|
244
|
+
or a session below it is live; renaming, archiving or
|
|
245
|
+
otherwise changing it in OpenCode does not make it run.
|
|
243
246
|
Each spawned session's brief says when to split its task and when to do it itself
|
|
244
247
|
([more](docs/reference.md#session-trees-and-their-limits)).
|
|
245
248
|
|
package/dist/memory/domain.d.ts
CHANGED
|
@@ -88,7 +88,10 @@ export interface TreeRecord {
|
|
|
88
88
|
readonly split: string;
|
|
89
89
|
/** Absent until the top states a split line worded otherwise than `split`; newest last, at most `MAX_REVISIONS`. */
|
|
90
90
|
readonly revisions?: ReadonlyArray<Revision>;
|
|
91
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* Whether, at the last refresh, no session in the tree ran or had a courier message waiting in its inbox for a turn, and
|
|
93
|
+
* every part had settled: none was out or stalled.
|
|
94
|
+
*/
|
|
92
95
|
readonly finished: boolean;
|
|
93
96
|
readonly recalled?: Recalled;
|
|
94
97
|
/** Absent until a session of the tree that is not a leaf wrote one; a second note replaces the first. */
|
package/dist/memory/events.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { claim } from "../platform/bounded.js";
|
|
2
|
-
import { childrenOf, indexedLineage } from "../tree/index.js";
|
|
2
|
+
import { childrenOf, endsIdle, indexedLineage } from "../tree/index.js";
|
|
3
3
|
import { refresh } from "./record.js";
|
|
4
4
|
const TURN_ENDS = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"]);
|
|
5
5
|
/**
|
|
@@ -16,13 +16,15 @@ export async function topOf(ports, state, sessionID) {
|
|
|
16
16
|
}
|
|
17
17
|
/** A turn ended in a tree: its record is written again, once however many subscriptions see the event. Returns the top refreshed. */
|
|
18
18
|
export async function noteTurnEnd(ports, state, event) {
|
|
19
|
-
// A shutdown's interruption is resumed on the next start: nothing ended.
|
|
20
|
-
if (event.data.reason === "shutdown")
|
|
21
|
-
return undefined;
|
|
22
19
|
const top = await topOf(ports, state, event.data.sessionID);
|
|
20
|
+
if (top === undefined)
|
|
21
|
+
return undefined;
|
|
22
|
+
// A shutdown's interruption is resumed on the next start, unless a plain decline ended the turn: its transcript ends on an aborted step.
|
|
23
|
+
if (event.data.reason === "shutdown" && !endsIdle(await ports.session.context({ sessionID: event.data.sessionID }).catch(() => [])))
|
|
24
|
+
return undefined;
|
|
23
25
|
// Claimed after the lookup, so other sessions' turns do not crowd `seen`; released on a failure, as a write is
|
|
24
26
|
// the same twice, so the hub's other subscription may still make it.
|
|
25
|
-
if (
|
|
27
|
+
if (!claim(state.seen, event.id))
|
|
26
28
|
return undefined;
|
|
27
29
|
try {
|
|
28
30
|
await refresh(ports, state, top, event.created ?? ports.now(), event.data.sessionID);
|
package/dist/memory/record.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { finite, isNotFound, millis } from "../platform/json.js";
|
|
2
|
-
import {
|
|
2
|
+
import { contextOf, reportState, stalledAmong, subtree, turnIn, turnOf, waitsInInbox } from "../tree/index.js";
|
|
3
3
|
import { isSettled, isStated, mergeParts, NOT_STATED, partStatus, replacingMention, revise, splitLinesOf, splitOf, unreadPart } from "./domain.js";
|
|
4
4
|
import { memoryStore } from "./store.js";
|
|
5
5
|
/** A session's replies, in order, each with when it was created, for its split lines; only the `finished` ones when asked. */
|
|
@@ -24,19 +24,27 @@ const keptUse = (last) => ({
|
|
|
24
24
|
/** A session's info, or `gone` when OpenCode no longer knows it; neither when it could not be read for another reason. */
|
|
25
25
|
const infoOf = (ports, sessionID) => ports.session.get({ sessionID }).then((info) => ({ info, gone: false }), (error) => ({ gone: isNotFound(error) }));
|
|
26
26
|
/**
|
|
27
|
-
* One session of the tree as its record keeps it, without its use when its messages were not read (only when its turn
|
|
28
|
-
* `ended` or it is `known` to no record yet), without a duration when its info was not, and whether it runs
|
|
27
|
+
* One session of the tree as its record keeps it, without its use when its messages were not read for it (only when its turn
|
|
28
|
+
* `ended` or it is `known` to no record yet), without a duration when its info was not, and whether it runs or is owed a turn.
|
|
29
29
|
*/
|
|
30
30
|
async function part(ports, windows, { entry, depth }, stalled, at, known, ended) {
|
|
31
31
|
const { sessionID } = entry;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
// Read once: for its use, and for whether it runs when its times do not show rest.
|
|
33
|
+
const readAt = Date.now();
|
|
34
|
+
const messages = ended || !known ? ports.session.context({ sessionID }) : undefined;
|
|
35
|
+
const [{ info, gone, running, endedAt }, use, report, waiting] = await Promise.all([
|
|
36
|
+
infoOf(ports, sessionID).then(async (one) => {
|
|
37
|
+
if (!one.info)
|
|
38
|
+
return { ...one, running: false };
|
|
39
|
+
const turn = messages ? turnIn(ports.session, sessionID, one.info.time, messages, readAt) : turnOf(ports.session, sessionID, one.info.time);
|
|
40
|
+
return { ...one, ...(await turn) };
|
|
41
|
+
}),
|
|
42
|
+
messages?.then((read) => useOf(read, windows), () => undefined),
|
|
35
43
|
reportState(ports.storage, sessionID).catch(() => undefined),
|
|
44
|
+
waitsInInbox(ports, sessionID).catch(() => true),
|
|
36
45
|
]);
|
|
37
|
-
const running = info !== undefined && busy(info.time);
|
|
38
46
|
const created = finite(millis(info?.time.created)) ?? entry.createdAt;
|
|
39
|
-
const end = info && !running &&
|
|
47
|
+
const end = info && !running && endedAt !== undefined ? endedAt : at;
|
|
40
48
|
const cost = finite(info?.cost);
|
|
41
49
|
const record = {
|
|
42
50
|
sessionID,
|
|
@@ -48,7 +56,8 @@ async function part(ports, windows, { entry, depth }, stalled, at, known, ended)
|
|
|
48
56
|
...(info ? { duration: Math.max(0, end - created) } : {}),
|
|
49
57
|
...(cost !== undefined ? { cost } : {}),
|
|
50
58
|
};
|
|
51
|
-
|
|
59
|
+
// A courier message waiting to be taken keeps it unfinished, unless OpenCode no longer knows it, which then takes none.
|
|
60
|
+
return { record, use, running: running || (waiting && !gone), unread: info === undefined, gone };
|
|
52
61
|
}
|
|
53
62
|
/** Of the parts last recorded, those the roster has forgotten before they settled whose session OpenCode no longer knows. */
|
|
54
63
|
async function goneSince(ports, parts, onRoster) {
|
|
@@ -56,6 +65,13 @@ async function goneSince(ports, parts, onRoster) {
|
|
|
56
65
|
const looked = await Promise.all(unsettled.map(async ({ sessionID }) => ({ sessionID, ...(await infoOf(ports, sessionID)) })));
|
|
57
66
|
return new Set(looked.flatMap((one) => (one.gone ? [one.sessionID] : [])));
|
|
58
67
|
}
|
|
68
|
+
/** The top's turn: from the transcript read at `readAt`, unless a stamp since then moved its times, which `turnOf` reads again. */
|
|
69
|
+
async function topTurnAfter(ports, top, info, messages, readAt) {
|
|
70
|
+
const fresh = await ports.session.get({ sessionID: top }).catch(() => undefined);
|
|
71
|
+
if (fresh && millis(fresh.time.updated) > readAt)
|
|
72
|
+
return turnOf(ports.session, top, fresh.time);
|
|
73
|
+
return messages ? turnIn(ports.session, top, info.time, messages, readAt) : turnOf(ports.session, top, info.time);
|
|
74
|
+
}
|
|
59
75
|
/**
|
|
60
76
|
* Writes the record of the tree under `top` as it stands at `at`, after the turn of `ended`; a part the roster has forgotten
|
|
61
77
|
* keeps its last recorded state, unless found gone before it settled. Nothing for a top unknown to OpenCode or with memory off.
|
|
@@ -77,9 +93,9 @@ export async function refresh(ports, state, top, at, ended) {
|
|
|
77
93
|
// The split lines are in the top's own replies, so only its own turn end can revise one stated already; another's reads
|
|
78
94
|
// them while none is stated, and only the replies finished, never a line the top is still writing.
|
|
79
95
|
const own = ended === top;
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
96
|
+
const topReadAt = Date.now();
|
|
97
|
+
const topMessages = own || !isStated(previous?.split ?? NOT_STATED) ? ports.session.context({ sessionID: top }) : undefined;
|
|
98
|
+
const replies = topMessages ? topMessages.then((messages) => repliesOf(messages, !own), () => []) : [];
|
|
83
99
|
const stalled = await stalledAmong(ports, nodes.map((node) => node.entry.sessionID));
|
|
84
100
|
const windows = [...state.windows.values()];
|
|
85
101
|
const known = new Set(previous?.parts.map((one) => one.sessionID));
|
|
@@ -87,7 +103,10 @@ export async function refresh(ports, state, top, at, ended) {
|
|
|
87
103
|
Promise.all(nodes.map((node) => part(ports, windows, node, stalled.has(node.entry.sessionID), at, known.has(node.entry.sessionID), node.entry.sessionID === ended))),
|
|
88
104
|
goneSince(ports, previous?.parts ?? [], new Set(nodes.map((node) => node.entry.sessionID))),
|
|
89
105
|
]);
|
|
90
|
-
|
|
106
|
+
// A courier message waiting in the top's inbox is a turn its transcript does not show yet, as for a part; a store that
|
|
107
|
+
// cannot be read counts as one waiting, as `runs` counts a transcript it cannot read as running.
|
|
108
|
+
const topTurn = topTurnAfter(ports, top, info, topMessages, topReadAt);
|
|
109
|
+
const [said, topRunning, topOwed] = await Promise.all([replies, topTurn.then((turn) => turn.running), waitsInInbox(ports, top).catch(() => true)]);
|
|
91
110
|
// Read again just before the write: a courier_recall or courier_note call, or another turn end's refresh, wrote meanwhile.
|
|
92
111
|
const latest = (await store.get(info.projectID, top).catch(() => previous)) ?? previous;
|
|
93
112
|
const last = new Map(latest?.parts.map((one) => [one.sessionID, one]));
|
|
@@ -114,7 +133,7 @@ export async function refresh(ports, state, top, at, ended) {
|
|
|
114
133
|
title: info.title ?? previous?.title ?? "",
|
|
115
134
|
split,
|
|
116
135
|
...(revisions ? { revisions } : {}),
|
|
117
|
-
finished: !
|
|
136
|
+
finished: !topRunning && !topOwed && !read.some(({ running }) => running) && parts.every((one) => isSettled(one.status)),
|
|
118
137
|
...(recalled ? { recalled } : {}),
|
|
119
138
|
...(lesson ? { lesson } : {}),
|
|
120
139
|
parts,
|
|
@@ -11,8 +11,8 @@ export interface LaterEntry {
|
|
|
11
11
|
}
|
|
12
12
|
export interface LaterPorts {
|
|
13
13
|
readonly storage: Storage;
|
|
14
|
-
/** `get` for the release of a join group, which waits for its parent's turn to end. */
|
|
15
|
-
readonly session: Pick<Context["session"], "synthetic" | "get">;
|
|
14
|
+
/** `get` for the release of a join group, which waits for its parent's turn to end; `get` and `context` for a missed wake (`ctx.session`). */
|
|
15
|
+
readonly session: Pick<Context["session"], "synthetic" | "get" | "context">;
|
|
16
16
|
readonly now: () => number;
|
|
17
17
|
readonly newID: () => string;
|
|
18
18
|
readonly log: (message: string) => void;
|
package/dist/tree/domain.d.ts
CHANGED
|
@@ -24,13 +24,37 @@ export declare function readNativeChildren(options: unknown): {
|
|
|
24
24
|
};
|
|
25
25
|
/** A session's times, as `session.get` gives them. */
|
|
26
26
|
type Times = Awaited<ReturnType<Context["session"]["get"]>>["time"];
|
|
27
|
-
/**
|
|
28
|
-
export
|
|
27
|
+
/** A session's times as `busy` reads them. */
|
|
28
|
+
export type BusyTimes = Pick<Times, "updated" | "idle">;
|
|
29
29
|
/**
|
|
30
|
-
* Whether a session
|
|
31
|
-
*
|
|
30
|
+
* Whether a session may run by its times: it has never finished a turn, or `updated` was stamped after the last one ended, as
|
|
31
|
+
* a step does, and as much that is no activity does (a rename, an archive, a waiting inbox item), so only its transcript tells.
|
|
32
32
|
*/
|
|
33
|
-
export declare const
|
|
33
|
+
export declare const busy: (time: BusyTimes) => boolean;
|
|
34
|
+
/** A stored message as `turnEnd` reads it: an assistant message's `error` says how its step ended. */
|
|
35
|
+
type Stored = {
|
|
36
|
+
readonly type: string;
|
|
37
|
+
readonly error?: {
|
|
38
|
+
readonly type: string;
|
|
39
|
+
};
|
|
40
|
+
readonly time?: {
|
|
41
|
+
readonly created?: unknown;
|
|
42
|
+
readonly completed?: unknown;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* The message a transcript's last turn ended on, unless one runs, past asides made since: an `idle` message, or a step
|
|
47
|
+
* interrupted with none after it, as a declined permission or dismissed question ends a turn, recording no `idle` (#326).
|
|
48
|
+
*/
|
|
49
|
+
export declare function turnEnd<M extends Stored>(messages: ReadonlyArray<M>): M | undefined;
|
|
50
|
+
export declare const endsIdle: (messages: ReadonlyArray<Stored>) => boolean;
|
|
51
|
+
/** When the turn `end` ended: the session's idle time for an `idle` message, else when its interrupted step ended. */
|
|
52
|
+
export declare function endedAt(end: Stored, time: BusyTimes): number | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Whether a session of a tree read is live, for a cut listing's order: it runs (its turn not failed or interrupted since its last
|
|
55
|
+
* prompt), waits on a request or form, or owes a report, but not, unlike for the limits, for a message waiting in its inbox.
|
|
56
|
+
*/
|
|
57
|
+
export declare const isLive: (running: boolean | undefined, waits: number, report: {
|
|
34
58
|
readonly owes: boolean;
|
|
35
59
|
readonly ended: boolean;
|
|
36
60
|
} | undefined) => boolean;
|
package/dist/tree/domain.js
CHANGED
|
@@ -23,13 +23,32 @@ export function readNativeChildren(options) {
|
|
|
23
23
|
return { nativeChildren: value === true, problems: [] };
|
|
24
24
|
return { nativeChildren: false, problems: [`nativeChildren must be true or false, not ${JSON.stringify(value)}; using false`] };
|
|
25
25
|
}
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Whether a session may run by its times: it has never finished a turn, or `updated` was stamped after the last one ended, as
|
|
28
|
+
* a step does, and as much that is no activity does (a rename, an archive, a waiting inbox item), so only its transcript tells.
|
|
29
|
+
*/
|
|
27
30
|
export const busy = (time) => time.idle === undefined || millis(time.updated) > millis(time.idle);
|
|
31
|
+
/** The messages the person's switch of agent or model, move or shell command appends, at rest or mid-turn alike. */
|
|
32
|
+
const ASIDES = new Set(["agent-switched", "model-switched", "location-switched", "shell"]);
|
|
33
|
+
/**
|
|
34
|
+
* The message a transcript's last turn ended on, unless one runs, past asides made since: an `idle` message, or a step
|
|
35
|
+
* interrupted with none after it, as a declined permission or dismissed question ends a turn, recording no `idle` (#326).
|
|
36
|
+
*/
|
|
37
|
+
export function turnEnd(messages) {
|
|
38
|
+
const last = messages.findLast((message) => !ASIDES.has(message.type));
|
|
39
|
+
return last?.type === "idle" || (last?.type === "assistant" && last.error?.type === "aborted") ? last : undefined;
|
|
40
|
+
}
|
|
41
|
+
export const endsIdle = (messages) => turnEnd(messages) !== undefined;
|
|
42
|
+
/** When the turn `end` ended: the session's idle time for an `idle` message, else when its interrupted step ended. */
|
|
43
|
+
export function endedAt(end, time) {
|
|
44
|
+
const at = end.type === "idle" ? millis(time.idle) : millis(end.time?.completed ?? end.time?.created);
|
|
45
|
+
return Number.isFinite(at) ? at : undefined;
|
|
46
|
+
}
|
|
28
47
|
/**
|
|
29
|
-
* Whether a session a tree
|
|
30
|
-
*
|
|
48
|
+
* Whether a session of a tree read is live, for a cut listing's order: it runs (its turn not failed or interrupted since its last
|
|
49
|
+
* prompt), waits on a request or form, or owes a report, but not, unlike for the limits, for a message waiting in its inbox.
|
|
31
50
|
*/
|
|
32
|
-
export const isLive = (
|
|
51
|
+
export const isLive = (running, waits, report) => running !== undefined && ((running && !report?.ended) || waits > 0 || (report?.owes ?? false));
|
|
33
52
|
/**
|
|
34
53
|
* Which sessions a listing of at most `max` keeps, of a tree read in the order it is drawn: the live ones first, then
|
|
35
54
|
* the newest, each with the sessions above it so the tree still draws; `leftOut` counts the rest.
|
package/dist/tree/events.d.ts
CHANGED
|
@@ -46,8 +46,8 @@ export declare function notePrompt(ports: WatchPorts, event: SessionEvent & {
|
|
|
46
46
|
};
|
|
47
47
|
}, inbox?: Map<string, string>): Promise<boolean>;
|
|
48
48
|
/**
|
|
49
|
-
* Notes that a spawned session's turn was stopped, which takes it out of its group without a report;
|
|
50
|
-
* stopped
|
|
49
|
+
* Notes that a spawned session's turn was stopped, which takes it out of its group without a report; a
|
|
50
|
+
* shutdown's is left to `reportDeclined`, as one stopped between steps resumes on the next start.
|
|
51
51
|
*/
|
|
52
52
|
export declare function noteInterrupted(ports: WatchPorts, event: SessionEvent): Promise<boolean>;
|
|
53
53
|
/**
|
|
@@ -64,6 +64,11 @@ export declare const together: (...suppliers: Waits[]) => Waits;
|
|
|
64
64
|
* prompt, unless it waits; finds the session by the reverse index alone. Returns the parents told.
|
|
65
65
|
*/
|
|
66
66
|
export declare function reportSilent(ports: WatchPorts, seen: Set<string>, event: SessionEvent, outside: Waits): Promise<string[]>;
|
|
67
|
+
/**
|
|
68
|
+
* A session's turn that OpenCode reports as a shutdown ended in a plain decline, if its transcript ends on an aborted step: its
|
|
69
|
+
* parent is told as for a turn that succeeded without a report. A real shutdown during a step reads the same, and is told too.
|
|
70
|
+
*/
|
|
71
|
+
export declare function reportDeclined(ports: WatchPorts, seen: Set<string>, event: SessionEvent, outside: Waits): Promise<string[]>;
|
|
67
72
|
/**
|
|
68
73
|
* Tells parents of a child's failed or silent turn, unless it waits on what `outside` says, and notes
|
|
69
74
|
* its prompts, interruptions and deletion; `after` a turn's end, seals the groups of its session.
|
package/dist/tree/events.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { envelope } from "../kernel/protocol.js";
|
|
2
2
|
import { claim, SEEN_MAX, setBounded } from "../platform/bounded.js";
|
|
3
|
+
import { endsIdle } from "./domain.js";
|
|
3
4
|
import { forgetChild, forgetDeleted } from "./forget.js";
|
|
4
5
|
import { hasCompleteGroup, leave, outIn } from "./group.js";
|
|
5
6
|
import { awaited, reportOf, stoppedSincePrompt } from "./report.js";
|
|
@@ -70,8 +71,8 @@ export async function notePrompt(ports, event, inbox) {
|
|
|
70
71
|
return true;
|
|
71
72
|
}
|
|
72
73
|
/**
|
|
73
|
-
* Notes that a spawned session's turn was stopped, which takes it out of its group without a report;
|
|
74
|
-
* stopped
|
|
74
|
+
* Notes that a spawned session's turn was stopped, which takes it out of its group without a report; a
|
|
75
|
+
* shutdown's is left to `reportDeclined`, as one stopped between steps resumes on the next start.
|
|
75
76
|
*/
|
|
76
77
|
export async function noteInterrupted(ports, event) {
|
|
77
78
|
if (event.data.reason === "shutdown")
|
|
@@ -165,8 +166,19 @@ export async function reportSilent(ports, seen, event, outside) {
|
|
|
165
166
|
}
|
|
166
167
|
return [entry.parentID];
|
|
167
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* A session's turn that OpenCode reports as a shutdown ended in a plain decline, if its transcript ends on an aborted step: its
|
|
171
|
+
* parent is told as for a turn that succeeded without a report. A real shutdown during a step reads the same, and is told too.
|
|
172
|
+
*/
|
|
173
|
+
export async function reportDeclined(ports, seen, event, outside) {
|
|
174
|
+
if (event.data.reason !== "shutdown" || (await childStore(ports.storage).indexedParent(event.data.sessionID)) === undefined)
|
|
175
|
+
return [];
|
|
176
|
+
if (!endsIdle(await ports.session.context({ sessionID: event.data.sessionID }).catch(() => [])))
|
|
177
|
+
return [];
|
|
178
|
+
return reportSilent(ports, seen, event, outside);
|
|
179
|
+
}
|
|
168
180
|
const TURN_ENDS = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"]);
|
|
169
|
-
/** A session's turn ended, sealing its groups: one that is complete has the scheduler deliver it now. */
|
|
181
|
+
/** A session's turn ended, sealing its groups: one that is complete has the scheduler deliver it now; not a shutdown's, which stamps no idle time. */
|
|
170
182
|
async function sealGroups(ports, event) {
|
|
171
183
|
if (event.data.reason === "shutdown")
|
|
172
184
|
return;
|
|
@@ -184,8 +196,10 @@ function routeTree(ports, state, outside, event) {
|
|
|
184
196
|
return reportFailure(ports, state.seen, event);
|
|
185
197
|
if (event.type === "session.execution.succeeded")
|
|
186
198
|
return reportSilent(ports, state.seen, event, outside);
|
|
187
|
-
if (event.type === "session.execution.interrupted")
|
|
188
|
-
|
|
199
|
+
if (event.type === "session.execution.interrupted") {
|
|
200
|
+
const interrupted = event;
|
|
201
|
+
return interrupted.data.reason === "shutdown" ? reportDeclined(ports, state.seen, interrupted, outside) : noteInterrupted(ports, interrupted);
|
|
202
|
+
}
|
|
189
203
|
if (event.type === "session.inbox.enqueued")
|
|
190
204
|
return noteEnqueued(ports, state.inbox, event);
|
|
191
205
|
if (event.type === "session.inbox.delivered")
|
package/dist/tree/group.js
CHANGED
|
@@ -131,8 +131,8 @@ async function releaseClaimed(ports, claimed, key, { parentID, group }) {
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
/**
|
|
134
|
-
* Whether the parent's turn that started a group's last member has ended, so no more join it; `gone` when OpenCode no
|
|
135
|
-
*
|
|
134
|
+
* Whether the parent's turn that started a group's last member has ended, so no more join it; `gone` when OpenCode no longer knows the
|
|
135
|
+
* parent. Only a turn ending stamps `time.idle`: a shutdown, or a plain permission decline reported as one, holds it until the next ends.
|
|
136
136
|
*/
|
|
137
137
|
async function sealed(ports, parentID, members) {
|
|
138
138
|
const joined = Math.max(...members.map((member) => member.joinedAt));
|
package/dist/tree/index.d.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
export { dirtyOf, headOf, inspectWorktree } from "./cleanup.js";
|
|
2
2
|
export { shapeContext } from "./context.js";
|
|
3
|
-
export {
|
|
3
|
+
export { endsIdle, lineageIn, nativeRootOf, readLimits, readNativeChildren } from "./domain.js";
|
|
4
4
|
export { together, treeEvents, waitsOn } from "./events.js";
|
|
5
5
|
export { deliverReleased } from "./group.js";
|
|
6
6
|
export { contextOf } from "./panel.js";
|
|
7
7
|
export type { CleanupPorts, CourierPorts, ModelWindow, Pending, PendingPrompt, RosterEntry, RosterStorage, ShownForm, SpawnGate, Waits, WatchPorts, WatchState, } from "./ports.js";
|
|
8
8
|
export { allEntries, answeringTop, childrenOf, indexedLineage, lineage, pruneExpired, reportState, RETENTION_MS, type ReportState } from "./roster.js";
|
|
9
|
+
export { runs, turnIn, turnOf, type Turn } from "./running.js";
|
|
9
10
|
export { subtree, type Below } from "./subtree.js";
|
|
10
11
|
export { registerTreeRpc } from "./rpc-handlers.js";
|
|
11
12
|
export { addTreeTools } from "./tools.js";
|
|
12
|
-
export { stalledAmong, wakeMissed } from "./wake.js";
|
|
13
|
+
export { stalledAmong, waitsInInbox, wakeMissed } from "./wake.js";
|
package/dist/tree/index.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
// for `src/index.ts`, the lineage and subtree queries and types `person/` reads, and what `memory/` reads of each session.
|
|
3
3
|
export { dirtyOf, headOf, inspectWorktree } from "./cleanup.js";
|
|
4
4
|
export { shapeContext } from "./context.js";
|
|
5
|
-
export {
|
|
5
|
+
export { endsIdle, lineageIn, nativeRootOf, readLimits, readNativeChildren } from "./domain.js";
|
|
6
6
|
export { together, treeEvents, waitsOn } from "./events.js";
|
|
7
7
|
export { deliverReleased } from "./group.js";
|
|
8
8
|
export { contextOf } from "./panel.js";
|
|
9
9
|
export { allEntries, answeringTop, childrenOf, indexedLineage, lineage, pruneExpired, reportState, RETENTION_MS } from "./roster.js";
|
|
10
|
+
export { runs, turnIn, turnOf } from "./running.js";
|
|
10
11
|
export { subtree } from "./subtree.js";
|
|
11
12
|
export { registerTreeRpc } from "./rpc-handlers.js";
|
|
12
13
|
export { addTreeTools } from "./tools.js";
|
|
13
|
-
export { stalledAmong, wakeMissed } from "./wake.js";
|
|
14
|
+
export { stalledAmong, waitsInInbox, wakeMissed } from "./wake.js";
|
package/dist/tree/limits.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { isNotFound } from "../platform/json.js";
|
|
2
|
-
import {
|
|
2
|
+
import { bySession, lineageBy } from "./domain.js";
|
|
3
3
|
import { reportOf } from "./report.js";
|
|
4
|
+
import { runs } from "./running.js";
|
|
4
5
|
import { childStore } from "./store.js";
|
|
5
6
|
import { childrenRefusal, depthRefusal, totalRefusal } from "./text.js";
|
|
7
|
+
import { waitsInInbox } from "./wake.js";
|
|
6
8
|
/**
|
|
7
|
-
* Whether a spawned session counts against the limits: it runs
|
|
8
|
-
*
|
|
9
|
+
* Whether a spawned session counts against the limits: it runs (its turn not failed or interrupted since its last prompt), owes
|
|
10
|
+
* its parent a report, or has a courier message waiting in its inbox that it is to be woken for. One OpenCode no longer knows does not.
|
|
9
11
|
*/
|
|
10
12
|
async function live(ports, sessionID) {
|
|
11
13
|
const [time, report] = await Promise.all([
|
|
@@ -15,8 +17,9 @@ async function live(ports, sessionID) {
|
|
|
15
17
|
if (time === null)
|
|
16
18
|
return false;
|
|
17
19
|
// One whose first prompt failed never ran, and has no idle time to tell so.
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
+
const running = time !== undefined && !report?.ended && (await runs(ports.session, sessionID, time));
|
|
21
|
+
// A waiting message is in no transcript until delivered, so `runs` misses it; a stalled one, which nothing wakes it for, is not counted.
|
|
22
|
+
return running || (report?.owes ?? false) || (await waitsInInbox(ports, sessionID, { stalled: false }).catch(() => true));
|
|
20
23
|
}
|
|
21
24
|
/**
|
|
22
25
|
* Checks the limits for a spawn from `parentID`, then holds its place in `gate` until `release`;
|
|
@@ -42,9 +45,9 @@ async function check(ports, parentID) {
|
|
|
42
45
|
const top = chain.at(-1)?.parentID ?? parentID;
|
|
43
46
|
const chains = new Map(entries.map((entry) => [entry.sessionID, lineageBy(parents, entry.sessionID)]));
|
|
44
47
|
const tree = entries.filter((entry) => chains.get(entry.sessionID).at(-1)?.parentID === top);
|
|
45
|
-
const
|
|
48
|
+
const counting = await Promise.all(tree.map((entry) => live(ports, entry.sessionID)));
|
|
46
49
|
// A session waiting on a live descendant is live too: the brief tells it to end its turn meanwhile.
|
|
47
|
-
const active = new Set(tree.filter((_, i) =>
|
|
50
|
+
const active = new Set(tree.filter((_, i) => counting[i]).flatMap((entry) => [entry.sessionID, ...chains.get(entry.sessionID).map((above) => above.parentID)]));
|
|
48
51
|
const counted = tree.filter((entry) => active.has(entry.sessionID));
|
|
49
52
|
// A spawn whose child is on the roster already is counted there, if it is live, not by its reservation.
|
|
50
53
|
const recorded = new Set(tree.map((entry) => entry.sessionID));
|
package/dist/tree/panel.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export declare function lastTool(messages: Messages): {
|
|
|
6
6
|
name: string;
|
|
7
7
|
at: number;
|
|
8
8
|
} | undefined;
|
|
9
|
-
/** How full a session's context is, as OpenCode's session view reads it (packages/tui/src/util/session.ts, v2.0
|
|
9
|
+
/** How full a session's context is, as OpenCode's session view reads it (packages/tui/src/util/session.ts, v2.1.0): the last usage since the last compaction over its model's window. */
|
|
10
10
|
export declare function contextOf(messages: Messages, windows: ReadonlyArray<ModelWindow>): number | undefined;
|
|
11
11
|
/** A request as the panel says what a paused session waits on: a permission request, or its first question. */
|
|
12
12
|
export declare const waitOf: (pending: Pending) => Wait;
|
package/dist/tree/panel.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { busy } from "./domain.js";
|
|
2
1
|
import { lastStatus, stoppedSincePrompt } from "./report.js";
|
|
3
2
|
import { standingIn } from "./status.js";
|
|
4
3
|
import { childStore } from "./store.js";
|
|
@@ -15,7 +14,7 @@ export function lastTool(messages) {
|
|
|
15
14
|
}
|
|
16
15
|
return undefined;
|
|
17
16
|
}
|
|
18
|
-
/** How full a session's context is, as OpenCode's session view reads it (packages/tui/src/util/session.ts, v2.0
|
|
17
|
+
/** How full a session's context is, as OpenCode's session view reads it (packages/tui/src/util/session.ts, v2.1.0): the last usage since the last compaction over its model's window. */
|
|
19
18
|
export function contextOf(messages, windows) {
|
|
20
19
|
const compacted = messages.findLastIndex((message) => message.type === "compaction" && message.status === "completed");
|
|
21
20
|
const last = messages.findLast((message, index) => index > compacted && message.type === "assistant" && message.tokens !== undefined);
|
|
@@ -44,7 +43,7 @@ function reportRow(report, status) {
|
|
|
44
43
|
}
|
|
45
44
|
/**
|
|
46
45
|
* What a session does: waiting on a request to the person or a form it shows them, stalled, running, idle, or gone when
|
|
47
|
-
* OpenCode no longer knows it. A message waiting in its inbox
|
|
46
|
+
* OpenCode no longer knows it. A message waiting in its inbox is no turn (`runs`); a stalled one reads stalled.
|
|
48
47
|
*/
|
|
49
48
|
function stateOf(info, waits, stalled, running) {
|
|
50
49
|
if (!info)
|
|
@@ -56,7 +55,7 @@ function stateOf(info, waits, stalled, running) {
|
|
|
56
55
|
return running ? "running" : "idle";
|
|
57
56
|
}
|
|
58
57
|
/** One session of the panel: what it is doing, its report and group standing, its last tool call, context use and cost. */
|
|
59
|
-
async function row(ports, windows, { entry, depth, info, pending, forms, report }, stalled) {
|
|
58
|
+
async function row(ports, windows, { entry, depth, info, running, endedAt, pending, forms, report }, stalled) {
|
|
60
59
|
const { sessionID } = entry;
|
|
61
60
|
const [messages, status, standing, stopped] = await Promise.all([
|
|
62
61
|
ports.session.context({ sessionID }).catch(() => []),
|
|
@@ -64,7 +63,6 @@ async function row(ports, windows, { entry, depth, info, pending, forms, report
|
|
|
64
63
|
entry.group ? standingIn(ports, entry, entry.group) : undefined,
|
|
65
64
|
stoppedSincePrompt(childStore(ports.storage), sessionID).catch(() => false),
|
|
66
65
|
]);
|
|
67
|
-
const running = info !== undefined && busy(info.time);
|
|
68
66
|
const tool = lastTool(messages);
|
|
69
67
|
const context = contextOf(messages, windows);
|
|
70
68
|
return {
|
|
@@ -80,7 +78,7 @@ async function row(ports, windows, { entry, depth, info, pending, forms, report
|
|
|
80
78
|
...(entry.group && standing ? { group: { name: entry.group, standing } } : {}),
|
|
81
79
|
isolated: entry.isolated,
|
|
82
80
|
created: info?.time.created ?? entry.createdAt,
|
|
83
|
-
...(info && !running &&
|
|
81
|
+
...(info && !running && endedAt !== undefined ? { idle: endedAt } : {}),
|
|
84
82
|
...(info ? { cost: info.cost } : {}),
|
|
85
83
|
...(context !== undefined ? { context } : {}),
|
|
86
84
|
...(tool ? { tool } : {}),
|
package/dist/tree/ports.d.ts
CHANGED
|
@@ -227,7 +227,7 @@ export interface SpawnGate {
|
|
|
227
227
|
}
|
|
228
228
|
export interface LimitPorts {
|
|
229
229
|
readonly storage: RosterStorage;
|
|
230
|
-
readonly session: Pick<Context["session"], "get">;
|
|
230
|
+
readonly session: Pick<Context["session"], "get" | "context">;
|
|
231
231
|
readonly limits: Limits;
|
|
232
232
|
readonly gate: SpawnGate;
|
|
233
233
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin";
|
|
2
|
+
import { type BusyTimes } from "./domain.js";
|
|
3
|
+
type Session = Pick<Plugin.Context["session"], "context">;
|
|
4
|
+
/** Whether a session runs and, when it does not, when its last turn ended, where known. */
|
|
5
|
+
export interface Turn {
|
|
6
|
+
readonly running: boolean;
|
|
7
|
+
readonly endedAt?: number;
|
|
8
|
+
}
|
|
9
|
+
type Messages = Awaited<ReturnType<Session["context"]>>;
|
|
10
|
+
/**
|
|
11
|
+
* Whether a session runs and, at rest, when its last turn ended: by its times when they show rest, else by its transcript
|
|
12
|
+
* (#324, #326), kept until the next stamp, a rest for `REST_MS` and not within `FRESH_MS` of it; one unread or empty runs.
|
|
13
|
+
*/
|
|
14
|
+
export declare function turnOf(session: Session, sessionID: string, time: BusyTimes, now?: number): Promise<Turn>;
|
|
15
|
+
/** `turnOf` for a caller that reads the transcript itself: it answers from these `messages`, read just now, and keeps the answer. */
|
|
16
|
+
export declare function turnIn(session: Session, sessionID: string, time: BusyTimes, messages: Promise<Messages>, now?: number): Promise<Turn>;
|
|
17
|
+
export declare const runs: (session: Session, sessionID: string, time: BusyTimes, now?: number) => Promise<boolean>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { millis } from "../platform/json.js";
|
|
2
|
+
import { setBounded } from "../platform/bounded.js";
|
|
3
|
+
import { busy, endedAt, turnEnd } from "./domain.js";
|
|
4
|
+
/** How many sessions' answers each session port remembers. */
|
|
5
|
+
const ANSWERS_MAX = 500;
|
|
6
|
+
/** How long after its stamp an answer of rest is read again: an item enqueued then may be delivered, stamping nothing until its step. */
|
|
7
|
+
const FRESH_MS = 10_000;
|
|
8
|
+
/** How long an answer of rest is kept: an item waiting since a lost wake is delivered on the wake sent again, which stamps nothing. */
|
|
9
|
+
const REST_MS = 30_000;
|
|
10
|
+
const RUNNING = { running: true };
|
|
11
|
+
const rest = (at) => (at === undefined ? { running: false } : { running: false, endedAt: at });
|
|
12
|
+
/** Each session port's last answer per session, which holds until the next stamp, but for those `turnOf` drops or keeps `until`. */
|
|
13
|
+
const answers = new WeakMap();
|
|
14
|
+
/**
|
|
15
|
+
* Whether a session runs and, at rest, when its last turn ended: by its times when they show rest, else by its transcript
|
|
16
|
+
* (#324, #326), kept until the next stamp, a rest for `REST_MS` and not within `FRESH_MS` of it; one unread or empty runs.
|
|
17
|
+
*/
|
|
18
|
+
export function turnOf(session, sessionID, time, now = Date.now()) {
|
|
19
|
+
return resolve(session, sessionID, time, now, () => session.context({ sessionID }), true);
|
|
20
|
+
}
|
|
21
|
+
/** `turnOf` for a caller that reads the transcript itself: it answers from these `messages`, read just now, and keeps the answer. */
|
|
22
|
+
export function turnIn(session, sessionID, time, messages, now = Date.now()) {
|
|
23
|
+
return resolve(session, sessionID, time, now, () => messages, false);
|
|
24
|
+
}
|
|
25
|
+
function resolve(session, sessionID, time, now, load, cached) {
|
|
26
|
+
if (!busy(time))
|
|
27
|
+
return Promise.resolve(rest(millis(time.idle)));
|
|
28
|
+
const known = answers.get(session) ?? new Map();
|
|
29
|
+
answers.set(session, known);
|
|
30
|
+
const stamp = `${millis(time.updated)} ${millis(time.idle)}`;
|
|
31
|
+
const found = known.get(sessionID);
|
|
32
|
+
if (cached && found?.stamp === stamp && now < found.until)
|
|
33
|
+
return found.turn;
|
|
34
|
+
// Only its own answer: a newer read of the session may have taken its place meanwhile.
|
|
35
|
+
const forget = () => {
|
|
36
|
+
if (known.get(sessionID) === answer)
|
|
37
|
+
known.delete(sessionID);
|
|
38
|
+
};
|
|
39
|
+
const turn = load().then((messages) => {
|
|
40
|
+
const end = turnEnd(messages);
|
|
41
|
+
if (!messages.length)
|
|
42
|
+
forget();
|
|
43
|
+
if (!end)
|
|
44
|
+
return RUNNING;
|
|
45
|
+
if (now - millis(time.updated) < FRESH_MS)
|
|
46
|
+
forget();
|
|
47
|
+
answer.until = now + REST_MS;
|
|
48
|
+
return rest(endedAt(end, time));
|
|
49
|
+
}, () => {
|
|
50
|
+
forget();
|
|
51
|
+
return RUNNING;
|
|
52
|
+
});
|
|
53
|
+
const answer = { stamp, turn, until: Number.POSITIVE_INFINITY };
|
|
54
|
+
known.delete(sessionID);
|
|
55
|
+
setBounded(known, sessionID, answer, ANSWERS_MAX);
|
|
56
|
+
return turn;
|
|
57
|
+
}
|
|
58
|
+
export const runs = (session, sessionID, time, now) => turnOf(session, sessionID, time, now).then((turn) => turn.running);
|