opencode-courier 0.5.0 → 0.7.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 +39 -30
- package/dist/index.js +9 -5
- package/dist/person/form.d.ts +4 -1
- package/dist/person/form.js +5 -0
- package/dist/person/index.d.ts +2 -1
- package/dist/person/index.js +1 -0
- package/dist/person/permission.d.ts +14 -0
- package/dist/person/permission.js +15 -0
- package/dist/person/ports.d.ts +8 -2
- package/dist/person/rpc-handlers.d.ts +37 -30
- package/dist/person/rpc-handlers.js +26 -16
- package/dist/person/rpc.d.ts +43 -24
- package/dist/person/rpc.js +4 -3
- package/dist/person/tui.d.ts +23 -12
- package/dist/tree/domain.d.ts +23 -0
- package/dist/tree/domain.js +26 -0
- package/dist/tree/events.d.ts +2 -2
- package/dist/tree/events.js +31 -4
- package/dist/tree/index.d.ts +2 -1
- package/dist/tree/index.js +2 -1
- package/dist/tree/panel.d.ts +7 -2
- package/dist/tree/panel.js +29 -17
- package/dist/tree/ports.d.ts +36 -0
- package/dist/tree/roster.d.ts +5 -0
- package/dist/tree/roster.js +5 -3
- package/dist/tree/rpc-handlers.d.ts +55 -6
- package/dist/tree/rpc-handlers.js +30 -1
- package/dist/tree/rpc.d.ts +111 -22
- package/dist/tree/rpc.js +30 -9
- package/dist/tree/stop.js +2 -0
- package/dist/tree/store.d.ts +4 -1
- package/dist/tree/store.js +51 -0
- package/dist/tree/subtree.d.ts +40 -9
- package/dist/tree/subtree.js +40 -19
- package/dist/tree/summary.d.ts +2 -2
- package/dist/tree/summary.js +17 -16
- package/dist/tree/text.js +4 -1
- package/dist/tree/tools.js +2 -1
- package/dist/tree/tui.d.ts +17 -6
- package/dist/tree/wake.d.ts +68 -0
- package/dist/tree/wake.js +119 -0
- package/dist/tui.js +617 -197
- package/package.json +1 -1
package/dist/tree/store.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Storage } from "../platform/storage.js";
|
|
2
|
-
import type { ChildStore, GroupStore } from "./ports.js";
|
|
2
|
+
import type { ChildStore, GroupStore, WakeStore } from "./ports.js";
|
|
3
3
|
/**
|
|
4
4
|
* What the reverse index holds for a child: the sessions above it, its parent first and the top
|
|
5
5
|
* session, which courier_spawn did not start, last; each is the parent of the one before it.
|
|
@@ -18,7 +18,10 @@ export declare const memberKey: (parentID: string, group: string, sessionID: str
|
|
|
18
18
|
/** The key prefix of a parent's groups, and of one group's members. */
|
|
19
19
|
export declare const groupsPrefix: (parentID: string) => string;
|
|
20
20
|
export declare const groupPrefix: (parentID: string, group: string) => string;
|
|
21
|
+
export declare const wakeKey: (sessionID: string, inboxID: string) => string;
|
|
21
22
|
/** The `ChildStore` over the plugin's storage. */
|
|
22
23
|
export declare function childStore(storage: Storage): ChildStore;
|
|
23
24
|
/** The `GroupStore` over the plugin's storage. */
|
|
24
25
|
export declare function groupStore(storage: Storage): GroupStore;
|
|
26
|
+
/** The `WakeStore` over the plugin's storage. */
|
|
27
|
+
export declare function wakeStore(storage: Storage): WakeStore;
|
package/dist/tree/store.js
CHANGED
|
@@ -42,6 +42,12 @@ export const memberKey = (parentID, group, sessionID) => `${GROUP_PREFIX}${paren
|
|
|
42
42
|
/** The key prefix of a parent's groups, and of one group's members. */
|
|
43
43
|
export const groupsPrefix = (parentID) => `${GROUP_PREFIX}${parentID}/`;
|
|
44
44
|
export const groupPrefix = (parentID, group) => `${groupsPrefix(parentID)}${group}/`;
|
|
45
|
+
/**
|
|
46
|
+
* The courier messages waiting in a session's inbox, each under `wake/<sessionID>/<inboxID>` until it is delivered, for
|
|
47
|
+
* the scheduler to wake a session whose wake was missed. docs/maintainers/architecture.md, § Storage.
|
|
48
|
+
*/
|
|
49
|
+
const WAKE_PREFIX = "wake/";
|
|
50
|
+
export const wakeKey = (sessionID, inboxID) => `${WAKE_PREFIX}${sessionID}/${inboxID}`;
|
|
45
51
|
function isReverse(value) {
|
|
46
52
|
const ancestors = value?.ancestors;
|
|
47
53
|
return Array.isArray(ancestors) && ancestors.length > 0 && ancestors.every((id) => typeof id === "string");
|
|
@@ -262,3 +268,48 @@ export function groupStore(storage) {
|
|
|
262
268
|
},
|
|
263
269
|
};
|
|
264
270
|
}
|
|
271
|
+
/** A watched message as stored under its key's session and inbox id; one that cannot be read is left out. */
|
|
272
|
+
function readWake(sessionID, inboxID, value) {
|
|
273
|
+
const given = obj(value);
|
|
274
|
+
const at = num(given.at);
|
|
275
|
+
const text = str(given.text);
|
|
276
|
+
if (at === undefined || text === undefined || typeof given.running !== "boolean")
|
|
277
|
+
return undefined;
|
|
278
|
+
const optional = (field) => {
|
|
279
|
+
const time = num(given[field]);
|
|
280
|
+
return time === undefined ? {} : { [field]: time };
|
|
281
|
+
};
|
|
282
|
+
const description = str(given.description);
|
|
283
|
+
return {
|
|
284
|
+
sessionID,
|
|
285
|
+
inboxID,
|
|
286
|
+
at,
|
|
287
|
+
text,
|
|
288
|
+
...(description !== undefined ? { description } : {}),
|
|
289
|
+
running: given.running,
|
|
290
|
+
...optional("idleAt"),
|
|
291
|
+
...optional("rewoken"),
|
|
292
|
+
...optional("stalled"),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/** The `WakeStore` over the plugin's storage. */
|
|
296
|
+
export function wakeStore(storage) {
|
|
297
|
+
const under = async (prefix) => {
|
|
298
|
+
const entries = await scanEntries(storage, prefix);
|
|
299
|
+
return entries.flatMap(({ key, value }) => {
|
|
300
|
+
const parts = `${prefix.slice(WAKE_PREFIX.length)}${key}`.split("/");
|
|
301
|
+
const wake = parts.length === 2 ? readWake(parts[0], parts[1], value) : undefined;
|
|
302
|
+
return wake ? [wake] : [];
|
|
303
|
+
});
|
|
304
|
+
};
|
|
305
|
+
return {
|
|
306
|
+
async put({ sessionID, inboxID, ...wake }) {
|
|
307
|
+
await storage.set(wakeKey(sessionID, inboxID), { ...wake });
|
|
308
|
+
},
|
|
309
|
+
of: (sessionID) => under(`${WAKE_PREFIX}${sessionID}/`),
|
|
310
|
+
all: () => under(WAKE_PREFIX),
|
|
311
|
+
async remove(sessionID, inboxID) {
|
|
312
|
+
await storage.remove(wakeKey(sessionID, inboxID));
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
package/dist/tree/subtree.d.ts
CHANGED
|
@@ -1,20 +1,50 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Plugin } from "@opencode/plugin";
|
|
2
|
+
import type { CourierPorts, Pending, RosterEntry, RosterStorage, ShownForm } from "./ports.js";
|
|
3
|
+
import { reportOf } from "./report.js";
|
|
4
|
+
import { type Exists } from "./roster.js";
|
|
2
5
|
/** A session below the one a subtree is read from, with how many levels below it is: a child is at depth 1. */
|
|
3
6
|
export interface Below {
|
|
4
7
|
readonly entry: RosterEntry;
|
|
5
8
|
readonly depth: number;
|
|
6
9
|
}
|
|
10
|
+
/** What a read drops: the entries past retention at `now`, as `courier_children` drops them; `exists` tells a worktree still there. */
|
|
11
|
+
export interface Retention {
|
|
12
|
+
readonly now: number;
|
|
13
|
+
readonly exists?: Exists;
|
|
14
|
+
}
|
|
7
15
|
/**
|
|
8
|
-
* The sessions started from a session, directly or through others, read one prefix scan per
|
|
9
|
-
*
|
|
16
|
+
* The sessions started from a session, directly or through others, every one of them, read one prefix scan per
|
|
17
|
+
* parent, in the order a tree is drawn; with `retention`, without the expired entries and what is under them.
|
|
10
18
|
*/
|
|
11
|
-
export declare function subtree(storage: RosterStorage, rootID: string,
|
|
19
|
+
export declare function subtree(storage: RosterStorage, rootID: string, retention?: Retention): Promise<{
|
|
12
20
|
nodes: Below[];
|
|
13
|
-
|
|
21
|
+
}>;
|
|
22
|
+
type Info = Awaited<ReturnType<Plugin.Context["session"]["get"]>>;
|
|
23
|
+
/** A session of a tree as a read looks it up: OpenCode's info of it, if it still knows it, and what it waits on. */
|
|
24
|
+
export interface Looked extends Below {
|
|
25
|
+
readonly info: Info | undefined;
|
|
26
|
+
readonly pending: ReadonlyArray<Pending>;
|
|
27
|
+
readonly forms: ReadonlyArray<ShownForm>;
|
|
28
|
+
}
|
|
29
|
+
/** A session as a listing looks it up: `Looked` plus its report state, and whether it is live (`isLive`). */
|
|
30
|
+
export interface Listed extends Looked {
|
|
31
|
+
readonly report: Awaited<ReturnType<typeof reportOf>>;
|
|
32
|
+
readonly live: boolean;
|
|
33
|
+
}
|
|
34
|
+
export type ReadPorts = Pick<CourierPorts, "storage" | "session" | "pending" | "forms" | "now" | "limits">;
|
|
35
|
+
/** Every session under a session, without those past retention, each looked up, in the order a tree is drawn. */
|
|
36
|
+
export declare function lookUp(ports: ReadPorts, rootID: string): Promise<Looked[]>;
|
|
37
|
+
/**
|
|
38
|
+
* The listing of at most `max` of the sessions under a session: the live ones first, then the newest, with what is
|
|
39
|
+
* above them, in the order a tree is drawn (`cut`); `leftOut` counts the rest.
|
|
40
|
+
*/
|
|
41
|
+
export declare function readTree(ports: ReadPorts, rootID: string, max?: number): Promise<{
|
|
42
|
+
kept: Listed[];
|
|
43
|
+
leftOut: number;
|
|
14
44
|
}>;
|
|
15
45
|
/**
|
|
16
|
-
* The subtree under a session
|
|
17
|
-
* its parent and its report state.
|
|
46
|
+
* The subtree under a session as courier_tree lists it (`readTree`): each session as courier_children shows it, plus
|
|
47
|
+
* its depth, its parent and its report state; `truncated` counts the older sessions left out.
|
|
18
48
|
*/
|
|
19
49
|
export declare function tree(ports: CourierPorts, rootID: string): Promise<{
|
|
20
50
|
truncated?: number | undefined;
|
|
@@ -41,7 +71,7 @@ export declare function tree(ports: CourierPorts, rootID: string): Promise<{
|
|
|
41
71
|
updated?: number | undefined;
|
|
42
72
|
idle?: number | undefined;
|
|
43
73
|
lastText?: string | undefined;
|
|
44
|
-
pending?: readonly
|
|
74
|
+
pending?: readonly Pending[] | undefined;
|
|
45
75
|
} | {
|
|
46
76
|
report?: {
|
|
47
77
|
status?: "done" | "partial" | "blocked" | "failed" | undefined;
|
|
@@ -61,7 +91,7 @@ export declare function tree(ports: CourierPorts, rootID: string): Promise<{
|
|
|
61
91
|
updated?: number | undefined;
|
|
62
92
|
idle?: number | undefined;
|
|
63
93
|
lastText?: string | undefined;
|
|
64
|
-
pending?: readonly
|
|
94
|
+
pending?: readonly Pending[] | undefined;
|
|
65
95
|
} | {
|
|
66
96
|
report?: {
|
|
67
97
|
status?: "done" | "partial" | "blocked" | "failed" | undefined;
|
|
@@ -99,3 +129,4 @@ export declare function tree(ports: CourierPorts, rootID: string): Promise<{
|
|
|
99
129
|
title: string;
|
|
100
130
|
})[];
|
|
101
131
|
}>;
|
|
132
|
+
export {};
|
package/dist/tree/subtree.js
CHANGED
|
@@ -1,24 +1,23 @@
|
|
|
1
|
+
import { busy, cut, isLive } from "./domain.js";
|
|
1
2
|
import { lastStatus, reportOf } from "./report.js";
|
|
3
|
+
import { isExpired } from "./roster.js";
|
|
2
4
|
import { described } from "./status.js";
|
|
3
5
|
import { childStore } from "./store.js";
|
|
4
6
|
/**
|
|
5
|
-
* The sessions started from a session, directly or through others, read one prefix scan per
|
|
6
|
-
*
|
|
7
|
+
* The sessions started from a session, directly or through others, every one of them, read one prefix scan per
|
|
8
|
+
* parent, in the order a tree is drawn; with `retention`, without the expired entries and what is under them.
|
|
7
9
|
*/
|
|
8
|
-
export async function subtree(storage, rootID,
|
|
10
|
+
export async function subtree(storage, rootID, retention) {
|
|
9
11
|
const children = childStore(storage);
|
|
10
12
|
const byParent = new Map();
|
|
11
13
|
const seen = new Set([rootID]);
|
|
12
|
-
let truncated = false;
|
|
13
14
|
const expand = async (level, depth) => {
|
|
14
|
-
if (!level.length
|
|
15
|
+
if (!level.length)
|
|
15
16
|
return;
|
|
16
17
|
const found = await Promise.all(level.map((parentID) => children.children(parentID)));
|
|
17
18
|
const next = [];
|
|
18
19
|
for (const [index, entries] of found.entries()) {
|
|
19
|
-
const
|
|
20
|
-
const kept = fresh.slice(0, Math.max(0, max - (seen.size - 1)));
|
|
21
|
-
truncated ||= kept.length < fresh.length;
|
|
20
|
+
const kept = entries.filter((entry) => !seen.has(entry.sessionID) && !(retention && isExpired(entry, retention.now, retention.exists)));
|
|
22
21
|
for (const entry of kept)
|
|
23
22
|
seen.add(entry.sessionID);
|
|
24
23
|
if (kept.length)
|
|
@@ -28,25 +27,47 @@ export async function subtree(storage, rootID, max = Number.POSITIVE_INFINITY) {
|
|
|
28
27
|
return expand(next, depth + 1);
|
|
29
28
|
};
|
|
30
29
|
await expand([rootID], 1);
|
|
31
|
-
return { nodes: drawn(byParent, rootID)
|
|
30
|
+
return { nodes: drawn(byParent, rootID) };
|
|
32
31
|
}
|
|
33
32
|
/** The nodes in the order a tree is drawn: each followed by what it started. */
|
|
34
33
|
function drawn(byParent, parentID) {
|
|
35
34
|
return (byParent.get(parentID) ?? []).flatMap((node) => [node, ...drawn(byParent, node.entry.sessionID)]);
|
|
36
35
|
}
|
|
36
|
+
/** Every session under a session, without those past retention, each looked up, in the order a tree is drawn. */
|
|
37
|
+
export async function lookUp(ports, rootID) {
|
|
38
|
+
const { nodes } = await subtree(ports.storage, rootID, { now: ports.now() });
|
|
39
|
+
return Promise.all(nodes.map(async (node) => {
|
|
40
|
+
const [info, pending, forms] = await Promise.all([
|
|
41
|
+
ports.session.get({ sessionID: node.entry.sessionID }).catch(() => undefined),
|
|
42
|
+
ports.pending(node.entry.sessionID).catch(() => []),
|
|
43
|
+
ports.forms(node.entry.sessionID).catch(() => []),
|
|
44
|
+
]);
|
|
45
|
+
// A form outlives its session's turn only when its withdrawal was missed, as while no instance followed the events.
|
|
46
|
+
return { ...node, info, pending, forms: info && busy(info.time) ? forms : [] };
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
37
49
|
/**
|
|
38
|
-
* The
|
|
39
|
-
*
|
|
50
|
+
* The listing of at most `max` of the sessions under a session: the live ones first, then the newest, with what is
|
|
51
|
+
* above them, in the order a tree is drawn (`cut`); `leftOut` counts the rest.
|
|
52
|
+
*/
|
|
53
|
+
export async function readTree(ports, rootID, max = ports.limits.maxTotal) {
|
|
54
|
+
const children = childStore(ports.storage);
|
|
55
|
+
const all = await Promise.all((await lookUp(ports, rootID)).map(async (node) => {
|
|
56
|
+
const report = await reportOf(children, node.entry.sessionID).catch(() => undefined);
|
|
57
|
+
return { ...node, report, live: isLive(node.info?.time, node.pending.length + node.forms.length, report) };
|
|
58
|
+
}));
|
|
59
|
+
const listing = cut(all.map((node) => ({ sessionID: node.entry.sessionID, parentID: node.entry.parentID, createdAt: node.entry.createdAt, live: node.live, node })), max);
|
|
60
|
+
return { kept: listing.kept.map(({ node }) => node), leftOut: listing.leftOut };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The subtree under a session as courier_tree lists it (`readTree`): each session as courier_children shows it, plus
|
|
64
|
+
* its depth, its parent and its report state; `truncated` counts the older sessions left out.
|
|
40
65
|
*/
|
|
41
66
|
export async function tree(ports, rootID) {
|
|
42
|
-
const {
|
|
67
|
+
const { kept, leftOut } = await readTree(ports, rootID);
|
|
43
68
|
const children = childStore(ports.storage);
|
|
44
|
-
const listed = await Promise.all(
|
|
45
|
-
const [info,
|
|
46
|
-
described(ports, entry),
|
|
47
|
-
reportOf(children, entry.sessionID).catch(() => undefined),
|
|
48
|
-
lastStatus(children, entry.sessionID).catch(() => undefined),
|
|
49
|
-
]);
|
|
69
|
+
const listed = await Promise.all(kept.map(async ({ entry, depth, report }) => {
|
|
70
|
+
const [info, last] = await Promise.all([described(ports, entry), lastStatus(children, entry.sessionID).catch(() => undefined)]);
|
|
50
71
|
return {
|
|
51
72
|
...info,
|
|
52
73
|
parentID: entry.parentID,
|
|
@@ -56,5 +77,5 @@ export async function tree(ports, rootID) {
|
|
|
56
77
|
: {}),
|
|
57
78
|
};
|
|
58
79
|
}));
|
|
59
|
-
return { sessionID: rootID, sessions: listed, ...(
|
|
80
|
+
return { sessionID: rootID, sessions: listed, ...(leftOut ? { truncated: leftOut } : {}) };
|
|
60
81
|
}
|
package/dist/tree/summary.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CourierPorts } from "./ports.js";
|
|
2
2
|
import type { Summary } from "./rpc.js";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Every session started under a session, counted by what it is doing (the session itself too, when it is stalled), and its
|
|
5
|
+
* open join groups, those of its own children, with how far each has reported.
|
|
6
6
|
*/
|
|
7
7
|
export declare function summary(ports: CourierPorts, sessionID: string): Promise<Summary>;
|
package/dist/tree/summary.js
CHANGED
|
@@ -1,35 +1,36 @@
|
|
|
1
1
|
import { busy } from "./domain.js";
|
|
2
2
|
import { groupStandings } from "./group.js";
|
|
3
3
|
import { groupStore } from "./store.js";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
import { lookUp } from "./subtree.js";
|
|
5
|
+
import { stalledAmong } from "./wake.js";
|
|
6
|
+
/**
|
|
7
|
+
* What a session is doing: waiting on a request it made or a form it shows, stalled, running, or idle; undefined when
|
|
8
|
+
* OpenCode no longer knows it. A message waiting in its inbox makes a stalled session look busy.
|
|
9
|
+
*/
|
|
10
|
+
function activity({ info, pending, forms, entry }, stalled) {
|
|
11
11
|
if (!info)
|
|
12
12
|
return undefined;
|
|
13
|
-
if (pending.length)
|
|
13
|
+
if (pending.length || forms.length)
|
|
14
14
|
return "waiting";
|
|
15
|
+
if (stalled.has(entry.sessionID))
|
|
16
|
+
return "stalled";
|
|
15
17
|
return busy(info.time) ? "running" : "idle";
|
|
16
18
|
}
|
|
17
19
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
+
* Every session started under a session, counted by what it is doing (the session itself too, when it is stalled), and its
|
|
21
|
+
* open join groups, those of its own children, with how far each has reported.
|
|
20
22
|
*/
|
|
21
23
|
export async function summary(ports, sessionID) {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
};
|
|
26
|
-
const [{ activities, truncated }, standings] = await Promise.all([counted(), groupStandings(groupStore(ports.storage), sessionID)]);
|
|
24
|
+
const [looked, standings] = await Promise.all([lookUp(ports, sessionID), groupStandings(groupStore(ports.storage), sessionID)]);
|
|
25
|
+
const stalled = await stalledAmong(ports, [sessionID, ...looked.map((one) => one.entry.sessionID)]);
|
|
26
|
+
const activities = looked.map((one) => activity(one, stalled));
|
|
27
27
|
const count = (kind) => activities.filter((item) => item === kind).length;
|
|
28
|
+
const stalls = count("stalled") + (stalled.has(sessionID) ? 1 : 0);
|
|
28
29
|
return {
|
|
29
30
|
running: count("running"),
|
|
30
31
|
waiting: count("waiting"),
|
|
31
32
|
idle: count("idle"),
|
|
33
|
+
...(stalls ? { stalled: stalls } : {}),
|
|
32
34
|
groups: standings.map(({ name, reported, members }) => ({ name, reported, members })),
|
|
33
|
-
...(truncated ? { truncated: ports.limits.maxTotal } : {}),
|
|
34
35
|
};
|
|
35
36
|
}
|
package/dist/tree/text.js
CHANGED
|
@@ -160,7 +160,10 @@ export function cleanupText(result) {
|
|
|
160
160
|
export function treeText(tree) {
|
|
161
161
|
if (!tree.sessions.length)
|
|
162
162
|
return "No sessions started with courier_spawn.";
|
|
163
|
-
|
|
163
|
+
if (tree.truncated === undefined)
|
|
164
|
+
return JSON.stringify(tree, null, 2);
|
|
165
|
+
const older = tree.truncated === 1 ? "1 older session is" : `${tree.truncated} older sessions are`;
|
|
166
|
+
const note = `\n${older} not listed: at most maxTotal are, the live ones first, then the newest; courier_tree on a listed session lists what is under it.`;
|
|
164
167
|
return JSON.stringify(tree, null, 2) + note;
|
|
165
168
|
}
|
|
166
169
|
/** What courier_stop did to each session, and to the messages and worktrees. */
|
package/dist/tree/tools.js
CHANGED
|
@@ -128,7 +128,8 @@ export function addTreeTools(tools, ports) {
|
|
|
128
128
|
options: { codemode: false },
|
|
129
129
|
description: "List the whole tree of sessions under this one (or under sessionID): every session started with courier_spawn " +
|
|
130
130
|
"from it, directly or through others, each with its depth, what courier_children shows, and whether it still " +
|
|
131
|
-
"owes a report, sent progress, or ended without one. For a one-off look, not for waiting
|
|
131
|
+
"owes a report, sent progress, or ended without one. For a one-off look, not for waiting. A long tree lists at most " +
|
|
132
|
+
"maxTotal sessions, the live ones first, then the newest, and says how many older ones are left out.",
|
|
132
133
|
input: TreeInput,
|
|
133
134
|
execute: async (input, context) => {
|
|
134
135
|
const result = await tree(ports.courier, input.sessionID || context.sessionID).catch(rethrow("courier_tree"));
|
package/dist/tree/tui.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode/plugin/tui";
|
|
2
|
-
import { type Row, type Stopped, type Summary, type Wait } from "./rpc.js";
|
|
2
|
+
import { type Forgotten, type Row, type Stopped, type Summary, type Wait } from "./rpc.js";
|
|
3
3
|
type Context = Plugin.Context;
|
|
4
|
+
/** The events after which a summary may read differently: a turn started or ended, a request or form, a session made or removed. */
|
|
5
|
+
export declare const CHANGES: Set<string>;
|
|
4
6
|
/** How long after the first of a burst of changes the entry asks the server again. */
|
|
5
7
|
export declare const SETTLE_MS = 250;
|
|
6
8
|
/** The panel's settle: each of its asks reads every row's messages, so a busy tree is asked at most once a second. */
|
|
@@ -9,8 +11,11 @@ export declare const PANEL_SETTLE_MS = 1000;
|
|
|
9
11
|
export declare const PANEL = "courier-tree";
|
|
10
12
|
/** How often the panel redraws its running times and how long ago each last tool call was. */
|
|
11
13
|
export declare const TICK_MS = 1000;
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The status entry's text, such as "courier 1 waiting on you · 2 running · group pair 1/2"; empty when there is nothing to show.
|
|
16
|
+
* Within `maxWidth` it keeps whole parts, most important first, and counts the groups it drops: "· +2 groups".
|
|
17
|
+
*/
|
|
18
|
+
export declare function summaryLine(summary: Summary, maxWidth?: number): string;
|
|
14
19
|
/** A span of time, coarsest first: "45s", "3m 05s", "2h 07m". */
|
|
15
20
|
export declare function duration(ms: number): string;
|
|
16
21
|
/** How many of a session's children have reported, out of those it started, among the rows read. */
|
|
@@ -23,21 +28,27 @@ export declare function bar({ reported, started }: {
|
|
|
23
28
|
reported: number;
|
|
24
29
|
started: number;
|
|
25
30
|
}, width?: number): string;
|
|
26
|
-
/** What a paused session waits on, such as "asks permission: shell echo hi". */
|
|
31
|
+
/** What a paused session waits on, such as "asks permission: shell echo hi" or "shows a form: Web Search". */
|
|
27
32
|
export declare function waitLine(wait: Wait): string;
|
|
28
33
|
/** What a row's session is doing, its report and its group standing: "idle · reported done · group pair held". */
|
|
29
34
|
export declare function statusLine(row: Row): string;
|
|
30
35
|
/** A leaf's activity: how long it has run, its last tool call and how long ago, and its cost. */
|
|
31
36
|
export declare function activityLine(row: Row, now: number): string;
|
|
32
|
-
/**
|
|
37
|
+
/** What a stalled session's row, or the panel of one, says of it: "a message waits in its inbox since 2m 05s, woken again in vain". */
|
|
38
|
+
export declare function stalledLine(since: number, now: number): string;
|
|
39
|
+
/** The lines a row shows under its title: an orchestrator's bar, a paused or stalled session's wait, or else a live leaf's activity. */
|
|
33
40
|
export declare function detailLines(row: Row, rows: ReadonlyArray<Row>, now: number): string[];
|
|
41
|
+
/** How often the footer and the panel ask again with no event: the scheduler flags a stalled session without one. */
|
|
42
|
+
export declare const REREAD_MS = 15000;
|
|
34
43
|
/**
|
|
35
44
|
* Adds the tree's entry to the prompt's footer: what the sessions under the open session do, asked of the RPC after each
|
|
36
|
-
* change
|
|
45
|
+
* change and every `REREAD_MS`; nothing on the home screen. Clicked while some wait on the person, it calls `onWaiting`.
|
|
37
46
|
*/
|
|
38
47
|
export declare function addTreeStatus(ctx: Context, onWaiting?: (sessionID: string) => void): () => void;
|
|
39
48
|
/** The toast after a stop: how many sessions were interrupted, and how many scheduled messages were cancelled. */
|
|
40
49
|
export declare function stoppedLine({ stopped, cancelled }: Stopped): string;
|
|
50
|
+
/** The toast after a forget, as courier_cleanup reports it: forgotten, its worktree removed or already gone, or kept and why. */
|
|
51
|
+
export declare function forgottenLine(title: string, result: Forgotten): string;
|
|
41
52
|
/** The usage the `/courier` command shows for an input it does not know. */
|
|
42
53
|
export declare const USAGE = "/courier tree opens the session tree; /courier stop [session] stops a session and those under it";
|
|
43
54
|
/**
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { GroupPorts, Wake, WatchPorts } from "./ports.js";
|
|
2
|
+
/** How long a courier message may wait in an idle session's inbox before its wake counts as missed. */
|
|
3
|
+
export declare const WAKE_MS = 30000;
|
|
4
|
+
/** OpenCode's `session.inbox.enqueued`, with the item's payload, which carries the courier's metadata. */
|
|
5
|
+
export interface Enqueued {
|
|
6
|
+
readonly created?: number;
|
|
7
|
+
readonly data: {
|
|
8
|
+
readonly sessionID: string;
|
|
9
|
+
readonly inboxID: string;
|
|
10
|
+
readonly item: {
|
|
11
|
+
readonly type: string;
|
|
12
|
+
readonly payload?: {
|
|
13
|
+
readonly text?: unknown;
|
|
14
|
+
readonly description?: unknown;
|
|
15
|
+
readonly metadata?: {
|
|
16
|
+
readonly source?: unknown;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** OpenCode's `session.inbox.delivered` and `session.inbox.cancelled`. */
|
|
23
|
+
export interface InboxSettled {
|
|
24
|
+
readonly data: {
|
|
25
|
+
readonly sessionID: string;
|
|
26
|
+
readonly inboxID: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** OpenCode's `session.execution.*` events, which frame a session's busy period. */
|
|
30
|
+
export interface Execution {
|
|
31
|
+
readonly created?: number;
|
|
32
|
+
readonly data: {
|
|
33
|
+
readonly sessionID: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Watches a courier message enqueued for a session until it is delivered, noting whether the session runs: a running
|
|
38
|
+
* turn takes it at its next step, an idle session is woken for it. Returns whether it is watched.
|
|
39
|
+
*/
|
|
40
|
+
export declare function watchWake(ports: WatchPorts, event: Enqueued): Promise<boolean>;
|
|
41
|
+
/** Stops watching a message once it is delivered or cancelled. */
|
|
42
|
+
export declare function settleWake(ports: Pick<WatchPorts, "storage">, event: InboxSettled): Promise<void>;
|
|
43
|
+
/** A turn started in a session: the messages waiting there are its to take. */
|
|
44
|
+
export declare function wakeStarted(ports: Pick<WatchPorts, "storage">, event: Execution): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* A session's turn ended: a message it took is no longer watched, as its delivery may have been missed by this
|
|
47
|
+
* subscription; one still waiting is watched as waiting in an idle session from now.
|
|
48
|
+
*/
|
|
49
|
+
export declare function wakeEnded(ports: WatchPorts, event: Execution & {
|
|
50
|
+
readonly data: {
|
|
51
|
+
readonly reason?: string;
|
|
52
|
+
};
|
|
53
|
+
}): Promise<void>;
|
|
54
|
+
/** Forgets the messages watched in a session's inbox: it was deleted, or stopped, which nothing is to wake again. */
|
|
55
|
+
export declare function forgetWakes(ports: Pick<WatchPorts, "storage">, sessionID: string): Promise<void>;
|
|
56
|
+
/** The ports the scheduler's tick wakes sessions through: what every hub member's `later` ports have. */
|
|
57
|
+
export type WakePorts = GroupPorts & {
|
|
58
|
+
readonly now: () => number;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Sends once more, under its inbox id, each courier message an idle session has left waiting for `WAKE_MS`, and flags
|
|
62
|
+
* stalled one still waiting `WAKE_MS` later; run by the scheduler's owner, `claimed` keeping a tick alongside off it.
|
|
63
|
+
*/
|
|
64
|
+
export declare function wakeMissed(ports: WakePorts, claimed: Set<string>): Promise<void>;
|
|
65
|
+
/** Whether a message has waited in an idle session past `WAKE_MS`, and since a wake again, if one was sent; a stalled one is done. */
|
|
66
|
+
export declare function missed(wake: Wake, now: number): boolean;
|
|
67
|
+
/** The sessions among `sessionIDs` flagged stalled, each with when its oldest stalled message was enqueued. */
|
|
68
|
+
export declare function stalledAmong(ports: Pick<WatchPorts, "storage">, sessionIDs: ReadonlyArray<string>): Promise<Map<string, number>>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { wakeStore } from "./store.js";
|
|
2
|
+
/** How long a courier message may wait in an idle session's inbox before its wake counts as missed. */
|
|
3
|
+
export const WAKE_MS = 30_000;
|
|
4
|
+
/** Whether a session's stored messages end with the marker of an ended turn; one with none yet is taken to run. */
|
|
5
|
+
const endsIdle = (messages) => messages.at(-1)?.type === "idle";
|
|
6
|
+
/**
|
|
7
|
+
* Watches a courier message enqueued for a session until it is delivered, noting whether the session runs: a running
|
|
8
|
+
* turn takes it at its next step, an idle session is woken for it. Returns whether it is watched.
|
|
9
|
+
*/
|
|
10
|
+
export async function watchWake(ports, event) {
|
|
11
|
+
const { sessionID, inboxID, item } = event.data;
|
|
12
|
+
const payload = item.payload;
|
|
13
|
+
if (item.type !== "synthetic" || payload?.metadata?.source !== "courier" || typeof payload.text !== "string")
|
|
14
|
+
return false;
|
|
15
|
+
const at = event.created ?? ports.now();
|
|
16
|
+
// Read after the enqueue: a turn it started has stored messages since, and an unknown session is taken to run.
|
|
17
|
+
const idle = await ports.session.context({ sessionID }).then(endsIdle, () => false);
|
|
18
|
+
await wakeStore(ports.storage).put({
|
|
19
|
+
sessionID,
|
|
20
|
+
inboxID,
|
|
21
|
+
at,
|
|
22
|
+
text: payload.text,
|
|
23
|
+
...(typeof payload.description === "string" ? { description: payload.description } : {}),
|
|
24
|
+
running: !idle,
|
|
25
|
+
...(idle ? { idleAt: at } : {}),
|
|
26
|
+
});
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
/** Stops watching a message once it is delivered or cancelled. */
|
|
30
|
+
export async function settleWake(ports, event) {
|
|
31
|
+
await wakeStore(ports.storage).remove(event.data.sessionID, event.data.inboxID);
|
|
32
|
+
}
|
|
33
|
+
/** A turn started in a session: the messages waiting there are its to take. */
|
|
34
|
+
export async function wakeStarted(ports, event) {
|
|
35
|
+
const store = wakeStore(ports.storage);
|
|
36
|
+
const waiting = await store.of(event.data.sessionID);
|
|
37
|
+
await Promise.all(waiting.filter((wake) => !wake.running).map(({ idleAt: _, ...wake }) => store.put({ ...wake, running: true })));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A session's turn ended: a message it took is no longer watched, as its delivery may have been missed by this
|
|
41
|
+
* subscription; one still waiting is watched as waiting in an idle session from now.
|
|
42
|
+
*/
|
|
43
|
+
export async function wakeEnded(ports, event) {
|
|
44
|
+
if (event.data.reason === "shutdown")
|
|
45
|
+
return;
|
|
46
|
+
const { sessionID } = event.data;
|
|
47
|
+
const store = wakeStore(ports.storage);
|
|
48
|
+
const waiting = await store.of(sessionID);
|
|
49
|
+
if (!waiting.length)
|
|
50
|
+
return;
|
|
51
|
+
const at = event.created ?? ports.now();
|
|
52
|
+
const stored = new Set((await ports.session.context({ sessionID }).catch(() => [])).map((message) => message.id));
|
|
53
|
+
await Promise.all(waiting.map((wake) => (stored.has(wake.inboxID) ? store.remove(sessionID, wake.inboxID) : store.put({ ...wake, running: false, idleAt: at }))));
|
|
54
|
+
}
|
|
55
|
+
/** Forgets the messages watched in a session's inbox: it was deleted, or stopped, which nothing is to wake again. */
|
|
56
|
+
export async function forgetWakes(ports, sessionID) {
|
|
57
|
+
const store = wakeStore(ports.storage);
|
|
58
|
+
await Promise.all((await store.of(sessionID)).map((wake) => store.remove(sessionID, wake.inboxID)));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Sends once more, under its inbox id, each courier message an idle session has left waiting for `WAKE_MS`, and flags
|
|
62
|
+
* stalled one still waiting `WAKE_MS` later; run by the scheduler's owner, `claimed` keeping a tick alongside off it.
|
|
63
|
+
*/
|
|
64
|
+
export async function wakeMissed(ports, claimed) {
|
|
65
|
+
const store = wakeStore(ports.storage);
|
|
66
|
+
const now = ports.now();
|
|
67
|
+
const due = (await store.all()).filter((wake) => missed(wake, now) && !claimed.has(claimOf(wake)));
|
|
68
|
+
for (const wake of due)
|
|
69
|
+
claimed.add(claimOf(wake));
|
|
70
|
+
await Promise.all(due.map((wake) => wakeAgain(ports, store, wake, now).finally(() => claimed.delete(claimOf(wake)))));
|
|
71
|
+
}
|
|
72
|
+
const claimOf = (wake) => `wake ${wake.sessionID} ${wake.inboxID}`;
|
|
73
|
+
/** Whether a message has waited in an idle session past `WAKE_MS`, and since a wake again, if one was sent; a stalled one is done. */
|
|
74
|
+
export function missed(wake, now) {
|
|
75
|
+
if (wake.running || wake.stalled !== undefined)
|
|
76
|
+
return false;
|
|
77
|
+
const since = Math.max(wake.idleAt ?? wake.at, wake.rewoken ?? 0);
|
|
78
|
+
return now - since >= WAKE_MS;
|
|
79
|
+
}
|
|
80
|
+
async function wakeAgain(ports, store, wake, now) {
|
|
81
|
+
try {
|
|
82
|
+
// Re-read: the message may have been delivered, or the session woken, since the scan.
|
|
83
|
+
const current = (await store.of(wake.sessionID)).find((one) => one.inboxID === wake.inboxID);
|
|
84
|
+
if (!current || !missed(current, now))
|
|
85
|
+
return;
|
|
86
|
+
if (current.rewoken !== undefined) {
|
|
87
|
+
ports.log(`courier wake: ${wake.sessionID} has not taken message ${wake.inboxID} since it was woken again; flagged stalled`);
|
|
88
|
+
return await store.put({ ...current, stalled: now });
|
|
89
|
+
}
|
|
90
|
+
// Noted before the send: the turn it starts may take the message, and drop the watch, before the send returns.
|
|
91
|
+
await store.put({ ...current, rewoken: now });
|
|
92
|
+
await ports.session.synthetic({
|
|
93
|
+
sessionID: wake.sessionID,
|
|
94
|
+
id: wake.inboxID,
|
|
95
|
+
text: wake.text,
|
|
96
|
+
...(wake.description !== undefined ? { description: wake.description } : {}),
|
|
97
|
+
metadata: { source: "courier", wake: "again" },
|
|
98
|
+
delivery: "steer",
|
|
99
|
+
}).catch(async (error) => {
|
|
100
|
+
await store.put(current);
|
|
101
|
+
throw error;
|
|
102
|
+
});
|
|
103
|
+
ports.log(`courier wake: ${wake.sessionID} left message ${wake.inboxID} waiting while idle; woken again`);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
ports.log(`courier wake: could not wake ${wake.sessionID} again for ${wake.inboxID}: ${String(error)}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** The sessions among `sessionIDs` flagged stalled, each with when its oldest stalled message was enqueued. */
|
|
110
|
+
export async function stalledAmong(ports, sessionIDs) {
|
|
111
|
+
const wanted = new Set(sessionIDs);
|
|
112
|
+
const stalled = new Map();
|
|
113
|
+
for (const wake of await wakeStore(ports.storage).all()) {
|
|
114
|
+
if (wake.stalled === undefined || !wanted.has(wake.sessionID))
|
|
115
|
+
continue;
|
|
116
|
+
stalled.set(wake.sessionID, Math.min(stalled.get(wake.sessionID) ?? wake.at, wake.at));
|
|
117
|
+
}
|
|
118
|
+
return stalled;
|
|
119
|
+
}
|