opencode-courier 0.10.1 → 0.10.3
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 +25 -13
- 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.js +3 -5
- 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 +5 -5
package/README.md
CHANGED
|
@@ -14,14 +14,15 @@ polling.
|
|
|
14
14
|
[](LICENSE)
|
|
15
15
|
[](#supported-opencode-version)
|
|
16
16
|
|
|
17
|
-
https://github.com/user-attachments/assets/
|
|
17
|
+
https://github.com/user-attachments/assets/d7cf74f8-15b3-4cc1-866e-b75ba0ac3f8a
|
|
18
18
|
|
|
19
|
-
The edge cases on the live terminal UI, narrated
|
|
19
|
+
The tree panel, the asks inbox, the edge cases and memory on the live terminal UI, narrated
|
|
20
|
+
([what it shows](#demos)).
|
|
20
21
|
|
|
21
22
|
## Quickstart
|
|
22
23
|
|
|
23
24
|
```bash
|
|
24
|
-
npm install -g @opencode/cli@2.1.
|
|
25
|
+
npm install -g @opencode/cli@2.1.2 # OpenCode V2, at the version courier is tested on
|
|
25
26
|
opencode plugin add opencode-courier # by name, so OpenCode offers new releases
|
|
26
27
|
opencode # in any folder
|
|
27
28
|
```
|
|
@@ -42,7 +43,7 @@ sometimes stall on a request; if nothing moves for a few minutes, send the promp
|
|
|
42
43
|
|
|
43
44
|
Courier is built on OpenCode V2's plugin API (`@opencode/plugin`), not the V1 one
|
|
44
45
|
(`@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.1.
|
|
46
|
+
the current one is tested on **OpenCode 2.1.2**. CI also runs the live suite on the newest OpenCode
|
|
46
47
|
release, on `main` and nightly. Older releases: [every release](docs/reference.md#the-opencode-version).
|
|
47
48
|
|
|
48
49
|
## Courier or the built-in subagent tool?
|
|
@@ -74,18 +75,26 @@ turn ends.
|
|
|
74
75
|
|
|
75
76
|
## Demos
|
|
76
77
|
|
|
77
|
-
The video at the top (
|
|
78
|
-
stand-in model, so each scene is real plugin behaviour:
|
|
78
|
+
The video at the top (3:01, narrated) is the TUI driven in `tmux` on the live test's scripted
|
|
79
|
+
stand-in model, so each scene is real plugin behaviour, the thing each step points at framed:
|
|
79
80
|
|
|
80
81
|
- **A join group:** one member reports and its report is held with the group, while the other waits
|
|
81
|
-
on a permission request, so the footer says one is waiting on you.
|
|
82
|
-
|
|
82
|
+
on a permission request, so the footer says one is waiting on you. `/courier tree` opens the panel,
|
|
83
|
+
with Digby and a row for each child saying what it waits on.
|
|
84
|
+
- **A click opens a child:** a click on a row opens that child's session, the panel following you
|
|
85
|
+
there: one waits on the permission, the other shows the report it sent.
|
|
86
|
+
- **The asks inbox:** back on the parent, you allow the permission from the inbox (`ctrl+x k`), and
|
|
87
|
+
both reports reach the parent in one message; the parent notes a lesson with `courier_note`.
|
|
83
88
|
- **A child whose turn fails:** OpenCode shows "Session failed", the parent is told, and the
|
|
84
89
|
`/courier tree` row reads `failed`.
|
|
85
90
|
- **A child that ends its turn without a report:** the parent is told, and the row says so.
|
|
86
91
|
- **`/courier stop`:** pick the session, confirm, and it is stopped.
|
|
87
92
|
- **A form only you can answer:** a child's web search shows a form; the footer, the panel row and
|
|
88
93
|
the asks inbox point to it, and answering it there wakes the parent.
|
|
94
|
+
- **The memory view:** `/courier memory` lists the project's session trees, each with how it was
|
|
95
|
+
split, how its parts went and its lesson; `p` pins one, listed first and never swept.
|
|
96
|
+
- **Recall from another tree:** a new tree's `courier_recall` lists the pinned record first, lesson
|
|
97
|
+
and all.
|
|
89
98
|
|
|
90
99
|
## Recursive orchestration
|
|
91
100
|
|
|
@@ -108,20 +117,20 @@ version than the pinned one writes one line to the server log
|
|
|
108
117
|
<!-- compat-table:start rows=3: generated by script/compat-table.ts from the release tags -->
|
|
109
118
|
| opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
|
|
110
119
|
|---|---|
|
|
120
|
+
| 0.10.2, 0.10.3 | 2.1.2 |
|
|
111
121
|
| 0.10.1 | 2.1.0 |
|
|
112
122
|
| 0.7.3, 0.8.0, 0.9.0, 0.10.0 | 2.0.32 |
|
|
113
|
-
| 0.7.2 | 2.0.31 |
|
|
114
123
|
<!-- compat-table:end -->
|
|
115
124
|
|
|
116
125
|
Older releases and their versions: [every release](docs/reference.md#the-opencode-version).
|
|
117
126
|
|
|
118
127
|
The [terminal part](#in-the-terminal) relies on the host to provide its UI modules and is tested on
|
|
119
|
-
OpenCode 2.1.
|
|
128
|
+
OpenCode 2.1.2 only; the server part works on older V2 hosts from 2.0.4 on.
|
|
120
129
|
|
|
121
130
|
Check yours with `opencode --version`, and install the matching CLI with:
|
|
122
131
|
|
|
123
132
|
```bash
|
|
124
|
-
npm install -g @opencode/cli@2.1.
|
|
133
|
+
npm install -g @opencode/cli@2.1.2
|
|
125
134
|
```
|
|
126
135
|
|
|
127
136
|
### The plugin
|
|
@@ -175,7 +184,7 @@ From a local clone, `bun install && npm run build`, then list its `dist` **direc
|
|
|
175
184
|
|---|---|
|
|
176
185
|
| `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
186
|
| `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. |
|
|
187
|
+
| `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
188
|
| `courier_children` | Lists the sessions this one (or a given one) started, with their state, worktree and join group. |
|
|
180
189
|
| `courier_tree` | Lists the whole tree under a session, depth first, with each one's depth and report state. |
|
|
181
190
|
| `courier_stop` | Interrupts a session below you and everything under it, and cancels their scheduled messages; `cleanup: true` removes their worktrees too. |
|
|
@@ -239,7 +248,10 @@ options in the plugin's `options`, in the global config:
|
|
|
239
248
|
| `maxTotal` | `20` | How many spawned sessions may be live at once in one tree. |
|
|
240
249
|
|
|
241
250
|
`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,
|
|
251
|
+
counts while it is live: its turn runs, it owes its parent a report, a message the plugin sent to
|
|
252
|
+
wake it waits undelivered in its inbox (from the moment `courier_send` returns, so a `courier_spawn` in the same step counts it),
|
|
253
|
+
or a session below it is live; renaming, archiving or
|
|
254
|
+
otherwise changing it in OpenCode does not make it run.
|
|
243
255
|
Each spawned session's brief says when to split its task and when to do it itself
|
|
244
256
|
([more](docs/reference.md#session-trees-and-their-limits)).
|
|
245
257
|
|
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.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";
|
|
@@ -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 {};
|