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.
Files changed (43) hide show
  1. package/README.md +39 -30
  2. package/dist/index.js +9 -5
  3. package/dist/person/form.d.ts +4 -1
  4. package/dist/person/form.js +5 -0
  5. package/dist/person/index.d.ts +2 -1
  6. package/dist/person/index.js +1 -0
  7. package/dist/person/permission.d.ts +14 -0
  8. package/dist/person/permission.js +15 -0
  9. package/dist/person/ports.d.ts +8 -2
  10. package/dist/person/rpc-handlers.d.ts +37 -30
  11. package/dist/person/rpc-handlers.js +26 -16
  12. package/dist/person/rpc.d.ts +43 -24
  13. package/dist/person/rpc.js +4 -3
  14. package/dist/person/tui.d.ts +23 -12
  15. package/dist/tree/domain.d.ts +23 -0
  16. package/dist/tree/domain.js +26 -0
  17. package/dist/tree/events.d.ts +2 -2
  18. package/dist/tree/events.js +31 -4
  19. package/dist/tree/index.d.ts +2 -1
  20. package/dist/tree/index.js +2 -1
  21. package/dist/tree/panel.d.ts +7 -2
  22. package/dist/tree/panel.js +29 -17
  23. package/dist/tree/ports.d.ts +36 -0
  24. package/dist/tree/roster.d.ts +5 -0
  25. package/dist/tree/roster.js +5 -3
  26. package/dist/tree/rpc-handlers.d.ts +55 -6
  27. package/dist/tree/rpc-handlers.js +30 -1
  28. package/dist/tree/rpc.d.ts +111 -22
  29. package/dist/tree/rpc.js +30 -9
  30. package/dist/tree/stop.js +2 -0
  31. package/dist/tree/store.d.ts +4 -1
  32. package/dist/tree/store.js +51 -0
  33. package/dist/tree/subtree.d.ts +40 -9
  34. package/dist/tree/subtree.js +40 -19
  35. package/dist/tree/summary.d.ts +2 -2
  36. package/dist/tree/summary.js +17 -16
  37. package/dist/tree/text.js +4 -1
  38. package/dist/tree/tools.js +2 -1
  39. package/dist/tree/tui.d.ts +17 -6
  40. package/dist/tree/wake.d.ts +68 -0
  41. package/dist/tree/wake.js +119 -0
  42. package/dist/tui.js +617 -197
  43. package/package.json +1 -1
@@ -8,7 +8,7 @@ declare const Session: Schema.Struct<{
8
8
  readonly depth: Schema.Int;
9
9
  readonly group: Schema.optionalKey<Schema.String>;
10
10
  }>;
11
- /** A permission request a session of the tree waits on. */
11
+ /** A permission request a session of the tree, the top among them, waits on. */
12
12
  declare const Permission: Schema.Struct<{
13
13
  readonly sessionID: Schema.String;
14
14
  readonly requestID: Schema.String;
@@ -16,6 +16,11 @@ declare const Permission: Schema.Struct<{
16
16
  readonly resources: Schema.$Array<Schema.String>;
17
17
  /** What "always" would allow from now on; absent when it is not offered. */
18
18
  readonly save: Schema.optionalKey<Schema.$Array<Schema.String>>;
19
+ /** The request below that allowing this one passes on: the top's courier_answer prompt. */
20
+ readonly passesOn: Schema.optionalKey<Schema.Struct<{
21
+ readonly sessionID: Schema.String;
22
+ readonly requestID: Schema.String;
23
+ }>>;
19
24
  }>;
20
25
  /** A question a session of the tree waits on; `stopped` when its call was cut off and the answer goes as a message. */
21
26
  declare const Question: Schema.Struct<{
@@ -45,6 +50,7 @@ declare const Asks: Schema.Struct<{
45
50
  readonly depth: Schema.Int;
46
51
  readonly group: Schema.optionalKey<Schema.String>;
47
52
  }>>;
53
+ /** The top session's own requests first, then those of the sessions below. */
48
54
  readonly permissions: Schema.$Array<Schema.Struct<{
49
55
  readonly sessionID: Schema.String;
50
56
  readonly requestID: Schema.String;
@@ -52,6 +58,11 @@ declare const Asks: Schema.Struct<{
52
58
  readonly resources: Schema.$Array<Schema.String>;
53
59
  /** What "always" would allow from now on; absent when it is not offered. */
54
60
  readonly save: Schema.optionalKey<Schema.$Array<Schema.String>>;
61
+ /** The request below that allowing this one passes on: the top's courier_answer prompt. */
62
+ readonly passesOn: Schema.optionalKey<Schema.Struct<{
63
+ readonly sessionID: Schema.String;
64
+ readonly requestID: Schema.String;
65
+ }>>;
55
66
  }>>;
56
67
  readonly questions: Schema.$Array<Schema.Struct<{
57
68
  readonly sessionID: Schema.String;
@@ -67,8 +78,6 @@ declare const Asks: Schema.Struct<{
67
78
  }>>;
68
79
  readonly stopped: Schema.optionalKey<Schema.Boolean>;
69
80
  }>>;
70
- /** Set when the tree had more sessions than `maxTotal`, which were not read. */
71
- readonly truncated: Schema.optionalKey<Schema.Int>;
72
81
  }>;
73
82
  export type Asks = typeof Asks.Type;
74
83
  export type AskedQuestion = typeof Question.Type;
@@ -93,20 +102,7 @@ export declare const personRpc: {
93
102
  readonly sessionID: Schema.String;
94
103
  }>;
95
104
  readonly output: import("effect/StandardSchema").StandardSchemaV1<{
96
- readonly questions: readonly {
97
- readonly sessionID: string;
98
- readonly requestID: string;
99
- readonly questions: readonly {
100
- readonly options: readonly {
101
- readonly label: string;
102
- readonly description: string;
103
- }[];
104
- readonly question: string;
105
- readonly header: string;
106
- readonly multiple?: boolean | undefined;
107
- }[];
108
- readonly stopped?: boolean | undefined;
109
- }[];
105
+ readonly top: string;
110
106
  readonly sessions: readonly {
111
107
  readonly sessionID: string;
112
108
  readonly title: string;
@@ -120,10 +116,11 @@ export declare const personRpc: {
120
116
  readonly requestID: string;
121
117
  readonly action: string;
122
118
  readonly save?: readonly string[] | undefined;
119
+ readonly passesOn?: {
120
+ readonly sessionID: string;
121
+ readonly requestID: string;
122
+ } | undefined;
123
123
  }[];
124
- readonly top: string;
125
- readonly truncated?: number | undefined;
126
- }, {
127
124
  readonly questions: readonly {
128
125
  readonly sessionID: string;
129
126
  readonly requestID: string;
@@ -138,6 +135,8 @@ export declare const personRpc: {
138
135
  }[];
139
136
  readonly stopped?: boolean | undefined;
140
137
  }[];
138
+ }, {
139
+ readonly top: string;
141
140
  readonly sessions: readonly {
142
141
  readonly sessionID: string;
143
142
  readonly title: string;
@@ -151,9 +150,25 @@ export declare const personRpc: {
151
150
  readonly requestID: string;
152
151
  readonly action: string;
153
152
  readonly save?: readonly string[] | undefined;
153
+ readonly passesOn?: {
154
+ readonly sessionID: string;
155
+ readonly requestID: string;
156
+ } | undefined;
157
+ }[];
158
+ readonly questions: readonly {
159
+ readonly sessionID: string;
160
+ readonly requestID: string;
161
+ readonly questions: readonly {
162
+ readonly options: readonly {
163
+ readonly label: string;
164
+ readonly description: string;
165
+ }[];
166
+ readonly question: string;
167
+ readonly header: string;
168
+ readonly multiple?: boolean | undefined;
169
+ }[];
170
+ readonly stopped?: boolean | undefined;
154
171
  }[];
155
- readonly top: string;
156
- readonly truncated?: number | undefined;
157
172
  }> & Schema.Struct<{
158
173
  /** The session at the top of the tree, which the sessions below were started from. */
159
174
  readonly top: Schema.String;
@@ -166,6 +181,7 @@ export declare const personRpc: {
166
181
  readonly depth: Schema.Int;
167
182
  readonly group: Schema.optionalKey<Schema.String>;
168
183
  }>>;
184
+ /** The top session's own requests first, then those of the sessions below. */
169
185
  readonly permissions: Schema.$Array<Schema.Struct<{
170
186
  readonly sessionID: Schema.String;
171
187
  readonly requestID: Schema.String;
@@ -173,6 +189,11 @@ export declare const personRpc: {
173
189
  readonly resources: Schema.$Array<Schema.String>;
174
190
  /** What "always" would allow from now on; absent when it is not offered. */
175
191
  readonly save: Schema.optionalKey<Schema.$Array<Schema.String>>;
192
+ /** The request below that allowing this one passes on: the top's courier_answer prompt. */
193
+ readonly passesOn: Schema.optionalKey<Schema.Struct<{
194
+ readonly sessionID: Schema.String;
195
+ readonly requestID: Schema.String;
196
+ }>>;
176
197
  }>>;
177
198
  readonly questions: Schema.$Array<Schema.Struct<{
178
199
  readonly sessionID: Schema.String;
@@ -188,8 +209,6 @@ export declare const personRpc: {
188
209
  }>>;
189
210
  readonly stopped: Schema.optionalKey<Schema.Boolean>;
190
211
  }>>;
191
- /** Set when the tree had more sessions than `maxTotal`, which were not read. */
192
- readonly truncated: Schema.optionalKey<Schema.Int>;
193
212
  }>;
194
213
  };
195
214
  readonly answer: {
@@ -14,7 +14,7 @@ const Session = Schema.Struct({
14
14
  depth: Count,
15
15
  group: Schema.optionalKey(Schema.String),
16
16
  });
17
- /** A permission request a session of the tree waits on. */
17
+ /** A permission request a session of the tree, the top among them, waits on. */
18
18
  const Permission = Schema.Struct({
19
19
  sessionID: Schema.String,
20
20
  requestID: Schema.String,
@@ -22,6 +22,8 @@ const Permission = Schema.Struct({
22
22
  resources: Schema.Array(Schema.String),
23
23
  /** What "always" would allow from now on; absent when it is not offered. */
24
24
  save: Schema.optionalKey(Schema.Array(Schema.String)),
25
+ /** The request below that allowing this one passes on: the top's courier_answer prompt. */
26
+ passesOn: Schema.optionalKey(Schema.Struct({ sessionID: Schema.String, requestID: Schema.String })),
25
27
  });
26
28
  /** One question of OpenCode's question tool. */
27
29
  const Prompt = Schema.Struct({
@@ -43,10 +45,9 @@ const Asks = Schema.Struct({
43
45
  top: Schema.String,
44
46
  /** The sessions below the top, in the order a tree is drawn. */
45
47
  sessions: Schema.Array(Session),
48
+ /** The top session's own requests first, then those of the sessions below. */
46
49
  permissions: Schema.Array(Permission),
47
50
  questions: Schema.Array(Question),
48
- /** Set when the tree had more sessions than `maxTotal`, which were not read. */
49
- truncated: Schema.optionalKey(Count),
50
51
  });
51
52
  /** What answering a question did; `problem` says why nothing was passed on, when it was refused. */
52
53
  const Answered = Schema.Struct({
@@ -1,17 +1,21 @@
1
1
  import type { Plugin } from "@opencode/plugin/tui";
2
- import type { AttentionSoundName } from "@opencode/plugin/tui/context";
3
2
  import { type AskedPermission, type AskedQuestion, type Asks, type TreeSession } from "./rpc.js";
4
3
  type Context = Plugin.Context;
5
4
  type FormInfo = NonNullable<ReturnType<Context["data"]["session"]["form"]["list"]>>[number];
6
5
  type FormField = FormInfo["fields"][number];
7
6
  type FormAnswer = Parameters<Context["data"]["session"]["form"]["reply"]>[0]["answer"];
8
- /** Something a session of the tree waits on the person for; `key` is unique across kinds. */
7
+ type FormValue = FormAnswer[string];
8
+ /**
9
+ * Something a session of the tree waits on the person for; `key` is unique across kinds. `prompted` marks a permission
10
+ * request the top session's courier_answer prompt asks about.
11
+ */
9
12
  export type Ask = {
10
13
  readonly key: string;
11
14
  readonly session: TreeSession;
12
15
  } & ({
13
16
  readonly kind: "permission";
14
17
  readonly request: AskedPermission;
18
+ readonly prompted?: boolean;
15
19
  } | {
16
20
  readonly kind: "question";
17
21
  readonly question: AskedQuestion;
@@ -23,23 +27,30 @@ export type Ask = {
23
27
  export declare const INBOX_COMMAND = "courier.asks";
24
28
  /** How long after the first of a burst of asks the inbox reads the tree again, to sound the new ones. */
25
29
  export declare const SETTLE_MS = 250;
26
- /** The asks of a tree in the order the inbox lists them: by the tree's order, each session's permissions, questions, then forms. */
30
+ /** The top session, at depth 0, as its own permission requests name it. */
31
+ export declare const topSession: (asks: Asks) => TreeSession;
32
+ /**
33
+ * The asks of a tree in the order the inbox lists them: the top session's own permission requests, then by the tree's
34
+ * order, each session's permissions, questions, then forms.
35
+ */
27
36
  export declare function asksOf(asks: Asks, forms: ReadonlyMap<string, ReadonlyArray<FormInfo>>): Ask[];
28
- /** Where an ask comes from: the titles from the top session down to the asking one, as "root › child › grandchild". */
29
- export declare function pathOf(asks: Asks, sessionID: string, topTitle: string): string;
37
+ /** Where an ask comes from: the titles from the top session down to the asking one, as "root › child › grandchild", each as the TUI knows it now, else as spawned. */
38
+ export declare function pathOf(asks: Asks, sessionID: string, topTitle: string, currentTitle?: (sessionID: string) => string | undefined): string;
30
39
  /** What an ask is, in one line. */
31
40
  export declare function askTitle(ask: Ask): string;
32
- /** The line under an ask: its depth and group, and that a question's call was cut off. */
41
+ /** The line under an ask: its depth (the top's own say so) and group, that a question's call was cut off, or that the top's courier_answer prompt asks for it. */
33
42
  export declare function askFooter(ask: Ask): string;
34
- /** The sound an ask arriving plays: a permission request's, or a question's for questions and forms. */
35
- export declare const soundOf: (ask: Ask) => AttentionSoundName;
36
- /** Whether a form field is shown, given the answers so far: hidden ones never, conditional ones when their conditions hold. */
37
- export declare function shown(field: FormField, answers: FormAnswer): boolean;
43
+ /** Whether a form field applies, given the answers so far (a hidden field's default among them): its `when` conditions hold, as OpenCode reads them (a condition on an unanswered field is false; on a multiselect, `eq` means includes). */
44
+ export declare function active(field: FormField, answers: FormAnswer): boolean;
45
+ /** What is wrong with a field's answer by the limits OpenCode's form service checks on reply, worded as its form view does; undefined when it fits. */
46
+ export declare function misfit(field: FormField, value: FormValue): string | undefined;
47
+ /** The limits of a typed field as a prompt's placeholder: "1-5", "at least 3 characters", "email"; undefined without any. */
48
+ export declare function limitsOf(field: FormField): string | undefined;
38
49
  /**
39
50
  * Adds the asks inbox: what the open session's courier tree waits on the person for, read through the person's RPC and
40
- * answered in dialogs, opened by its command; a sound for each new ask. Returns its opener and its cleanup.
51
+ * answered in dialogs, opened by its command, its open list read again on `follows`; a sound for each new ask.
41
52
  */
42
- export declare function addAsksInbox(ctx: Context): {
53
+ export declare function addAsksInbox(ctx: Context, follows: ReadonlySet<string>): {
43
54
  open: (sessionID?: string | undefined) => Promise<void>;
44
55
  cleanup: () => void;
45
56
  };
@@ -26,6 +26,29 @@ export declare function readNativeChildren(options: unknown): {
26
26
  type Times = Awaited<ReturnType<Context["session"]["get"]>>["time"];
27
27
  /** Whether a session with these times runs: it has never finished a turn, or something reached it after the last one ended. */
28
28
  export declare const busy: (time: Pick<Times, "updated" | "idle">) => boolean;
29
+ /**
30
+ * Whether a session a tree is read of is live: it runs (as the limits count it: busy, and its turn has not failed or
31
+ * been interrupted since its last prompt), waits on a request or form, or owes a report; one OpenCode no longer knows is not.
32
+ */
33
+ export declare const isLive: (time: Pick<Times, "updated" | "idle"> | undefined, waits: number, report: {
34
+ readonly owes: boolean;
35
+ readonly ended: boolean;
36
+ } | undefined) => boolean;
37
+ /** A session of a tree read, as `cut` takes it: where it is in the tree, when it was started, and whether it is live. */
38
+ export interface Cuttable {
39
+ readonly sessionID: string;
40
+ readonly parentID: string;
41
+ readonly createdAt: number;
42
+ readonly live: boolean;
43
+ }
44
+ /**
45
+ * Which sessions a listing of at most `max` keeps, of a tree read in the order it is drawn: the live ones first, then
46
+ * the newest, each with the sessions above it so the tree still draws; `leftOut` counts the rest.
47
+ */
48
+ export declare function cut<T extends Cuttable>(nodes: ReadonlyArray<T>, max: number): {
49
+ kept: T[];
50
+ leftOut: number;
51
+ };
29
52
  /** A session started with courier_spawn, recorded under the session that started it. */
30
53
  export interface RosterEntry {
31
54
  readonly sessionID: string;
@@ -25,6 +25,32 @@ export function readNativeChildren(options) {
25
25
  }
26
26
  /** Whether a session with these times runs: it has never finished a turn, or something reached it after the last one ended. */
27
27
  export const busy = (time) => time.idle === undefined || millis(time.updated) > millis(time.idle);
28
+ /**
29
+ * Whether a session a tree is read of is live: it runs (as the limits count it: busy, and its turn has not failed or
30
+ * been interrupted since its last prompt), waits on a request or form, or owes a report; one OpenCode no longer knows is not.
31
+ */
32
+ export const isLive = (time, waits, report) => time !== undefined && ((busy(time) && !report?.ended) || waits > 0 || (report?.owes ?? false));
33
+ /**
34
+ * 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
+ * the newest, each with the sessions above it so the tree still draws; `leftOut` counts the rest.
36
+ */
37
+ export function cut(nodes, max) {
38
+ const byID = new Map(nodes.map((node) => [node.sessionID, node]));
39
+ const ranked = nodes
40
+ .map((node, order) => ({ node, order }))
41
+ .sort((a, b) => Number(b.node.live) - Number(a.node.live) || b.node.createdAt - a.node.createdAt || a.order - b.order);
42
+ const kept = new Set();
43
+ for (const { node } of ranked) {
44
+ const chain = [];
45
+ for (let above = node; above && !kept.has(above.sessionID); above = byID.get(above.parentID))
46
+ chain.push(above.sessionID);
47
+ if (kept.size + chain.length > max)
48
+ continue;
49
+ for (const sessionID of chain)
50
+ kept.add(sessionID);
51
+ }
52
+ return { kept: nodes.filter((node) => kept.has(node.sessionID)), leftOut: nodes.length - kept.size };
53
+ }
28
54
  /** `lineage` over roster entries already read. */
29
55
  export function lineageIn(entries, sessionID) {
30
56
  return lineageBy(bySession(entries), sessionID);
@@ -51,8 +51,8 @@ export declare function notePrompt(ports: WatchPorts, event: SessionEvent & {
51
51
  */
52
52
  export declare function noteInterrupted(ports: WatchPorts, event: SessionEvent): Promise<boolean>;
53
53
  /**
54
- * Drops the report state, and the groups, of a session OpenCode deleted, and the report state of the sessions it
55
- * started, whose reports can no longer be delivered; it leaves its own group. Returns the sessions forgotten.
54
+ * Forgets what a deleted session leaves (report state, groups) and, unless isolated or a parent on the roster, its
55
+ * entry. Returns the sessions forgotten.
56
56
  */
57
57
  export declare function noteDeleted(ports: WatchPorts, event: SessionEvent): Promise<string[]>;
58
58
  /** A `Waits` asking `suppliers` one after another, cheapest first: the first that waits answers. */
@@ -1,11 +1,12 @@
1
1
  import { envelope } from "../kernel/protocol.js";
2
2
  import { claim, SEEN_MAX, setBounded } from "../platform/bounded.js";
3
- import { forgetDeleted } from "./forget.js";
3
+ import { forgetChild, forgetDeleted } from "./forget.js";
4
4
  import { hasCompleteGroup, leave, outIn } from "./group.js";
5
5
  import { awaited, reportOf, stoppedSincePrompt } from "./report.js";
6
6
  import { childStore, groupStore } from "./store.js";
7
7
  import { lastReply } from "./status.js";
8
8
  import { failureNotice, silentNotice } from "./text.js";
9
+ import { forgetWakes, settleWake, wakeEnded, wakeStarted, watchWake } from "./wake.js";
9
10
  /**
10
11
  * Tells the parent of a child whose turn failed, since a child that cannot reach its model cannot
11
12
  * call courier_send. Returns the parents told.
@@ -88,8 +89,8 @@ export async function noteInterrupted(ports, event) {
88
89
  return true;
89
90
  }
90
91
  /**
91
- * Drops the report state, and the groups, of a session OpenCode deleted, and the report state of the sessions it
92
- * started, whose reports can no longer be delivered; it leaves its own group. Returns the sessions forgotten.
92
+ * Forgets what a deleted session leaves (report state, groups) and, unless isolated or a parent on the roster, its
93
+ * entry. Returns the sessions forgotten.
93
94
  */
94
95
  export async function noteDeleted(ports, event) {
95
96
  const { sessionID } = event.data;
@@ -99,6 +100,8 @@ export async function noteDeleted(ports, event) {
99
100
  const entry = parentID === undefined ? undefined : await children.entry(parentID, sessionID);
100
101
  const member = entry && (await outIn(ports, entry));
101
102
  await forgetDeleted(ports, sessionID, forgotten, member, event.created);
103
+ if (parentID !== undefined && entry && !entry.isolated && started.length === 0)
104
+ await forgetChild(ports.storage, parentID, sessionID);
102
105
  return forgotten;
103
106
  }
104
107
  /** A `Waits` asking `suppliers` one after another, cheapest first: the first that waits answers. */
@@ -191,13 +194,37 @@ function routeTree(ports, state, outside, event) {
191
194
  return noteDeleted(ports, event);
192
195
  return undefined;
193
196
  }
197
+ /** The events that move a courier message waiting in a session's inbox: enqueued, taken, cancelled, and the turns around it. */
198
+ function routeWake(ports, event) {
199
+ if (event.type === "session.inbox.enqueued")
200
+ return watchWake(ports, event);
201
+ if (event.type === "session.inbox.delivered" || event.type === "session.inbox.cancelled")
202
+ return settleWake(ports, event);
203
+ if (event.type === "session.execution.started")
204
+ return wakeStarted(ports, event);
205
+ if (TURN_ENDS.has(event.type))
206
+ return wakeEnded(ports, event);
207
+ if (event.type === "session.deleted")
208
+ return forgetWakes(ports, event.data.sessionID);
209
+ return undefined;
210
+ }
211
+ /** The tree's handling of an event alongside the wake watch's, which touches other keys and never fails the tree's. */
212
+ async function route(ports, state, outside, event) {
213
+ const watched = routeWake(ports, event)?.catch((error) => ports.log(`courier watch: wake of ${event.type}: ${String(error)}`));
214
+ try {
215
+ return await routeTree(ports, state, outside, event);
216
+ }
217
+ finally {
218
+ await watched;
219
+ }
220
+ }
194
221
  /**
195
222
  * Tells parents of a child's failed or silent turn, unless it waits on what `outside` says, and notes
196
223
  * its prompts, interruptions and deletion; `after` a turn's end, seals the groups of its session.
197
224
  */
198
225
  export function treeEvents(state, outside) {
199
226
  return {
200
- handle: (ports, event) => routeTree(ports, state, outside, event),
227
+ handle: (ports, event) => route(ports, state, outside, event),
201
228
  // After: a silent end is judged while the group the nudge may deliver is still there to wait on.
202
229
  after: (ports, event) => (TURN_ENDS.has(event.type) ? sealGroups(ports, event) : undefined),
203
230
  };
@@ -3,8 +3,9 @@ export { shapeContext } from "./context.js";
3
3
  export { lineageIn, nativeRootOf, readLimits, readNativeChildren } from "./domain.js";
4
4
  export { together, treeEvents, waitsOn } from "./events.js";
5
5
  export { deliverReleased } from "./group.js";
6
- export type { CleanupPorts, CourierPorts, Pending, PendingPrompt, RosterEntry, RosterStorage, SpawnGate, Waits, WatchPorts, WatchState, } from "./ports.js";
6
+ export type { CleanupPorts, CourierPorts, Pending, PendingPrompt, RosterEntry, RosterStorage, ShownForm, SpawnGate, Waits, WatchPorts, WatchState, } from "./ports.js";
7
7
  export { allEntries, answeringTop, lineage, pruneExpired, RETENTION_MS } from "./roster.js";
8
8
  export { subtree } from "./subtree.js";
9
9
  export { registerTreeRpc } from "./rpc-handlers.js";
10
10
  export { addTreeTools } from "./tools.js";
11
+ export { wakeMissed } from "./wake.js";
@@ -1,4 +1,4 @@
1
- // What the rest of the plugin uses of the session tree: its tools, RPC, event module, context hook and tick job
1
+ // What the rest of the plugin uses of the session tree: its tools, RPC, event module, context hook and tick jobs
2
2
  // for `src/index.ts`, and the lineage and subtree queries and types `person/` reads.
3
3
  export { dirtyOf, headOf, inspectWorktree } from "./cleanup.js";
4
4
  export { shapeContext } from "./context.js";
@@ -9,3 +9,4 @@ export { allEntries, answeringTop, lineage, pruneExpired, RETENTION_MS } from ".
9
9
  export { subtree } from "./subtree.js";
10
10
  export { registerTreeRpc } from "./rpc-handlers.js";
11
11
  export { addTreeTools } from "./tools.js";
12
+ export { wakeMissed } from "./wake.js";
@@ -1,4 +1,4 @@
1
- import type { CourierPorts, Pending } from "./ports.js";
1
+ import type { CourierPorts, Pending, ShownForm } from "./ports.js";
2
2
  import type { Tree, Wait } from "./rpc.js";
3
3
  type Messages = Awaited<ReturnType<CourierPorts["session"]["context"]>>;
4
4
  /** The last tool call in a session's messages, and when it was made. */
@@ -8,6 +8,11 @@ export declare function lastTool(messages: Messages): {
8
8
  } | undefined;
9
9
  /** A request as the panel says what a paused session waits on: a permission request, or its first question. */
10
10
  export declare const waitOf: (pending: Pending) => Wait;
11
- /** The tree panel's rows: the sessions courier_tree reads under a session, at most `maxTotal`, each as `row` reads it. */
11
+ /** A form a session shows, as the panel says what it waits on. */
12
+ export declare const formWait: (form: ShownForm) => Wait;
13
+ /**
14
+ * The tree panel's rows: the sessions under a session as courier_tree lists them, at most `maxTotal`, the live ones
15
+ * first, then the newest, each as `row` reads it; `truncated` counts the older ones left out, and `stalled` says the root is.
16
+ */
12
17
  export declare function panel(ports: CourierPorts, rootID: string): Promise<Tree>;
13
18
  export {};
@@ -1,8 +1,9 @@
1
1
  import { busy } from "./domain.js";
2
- import { lastStatus, reportOf } from "./report.js";
2
+ import { lastStatus, stoppedSincePrompt } from "./report.js";
3
3
  import { standingIn } from "./status.js";
4
4
  import { childStore } from "./store.js";
5
- import { subtree } from "./subtree.js";
5
+ import { readTree } from "./subtree.js";
6
+ import { stalledAmong } from "./wake.js";
6
7
  /** The last tool call in a session's messages, and when it was made. */
7
8
  export function lastTool(messages) {
8
9
  for (const message of messages.toReversed()) {
@@ -18,6 +19,8 @@ export function lastTool(messages) {
18
19
  export const waitOf = (pending) => pending.type === "permission"
19
20
  ? { type: "permission", action: pending.action, resources: [...pending.resources] }
20
21
  : { type: "question", question: pending.questions[0]?.question ?? "" };
22
+ /** A form a session shows, as the panel says what it waits on. */
23
+ export const formWait = (form) => ({ type: "form", title: form.title });
21
24
  /** A session's report since its last prompt, as the panel shows it; none for one spawned before this was kept. */
22
25
  function reportRow(report, status) {
23
26
  if (!report)
@@ -28,25 +31,27 @@ function reportRow(report, status) {
28
31
  return { report: { state: "ended", progressed: report.progressed } };
29
32
  return { report: { state: "reported", progressed: report.progressed, ...(status ? { status } : {}) } };
30
33
  }
31
- /** What a session does: waiting on a request to the person, running, idle, or gone when OpenCode no longer knows it. */
32
- function stateOf(info, waits, running) {
34
+ /**
35
+ * What a session does: waiting on a request to the person or a form it shows them, stalled, running, idle, or gone when
36
+ * OpenCode no longer knows it. A message waiting in its inbox makes a stalled session look busy.
37
+ */
38
+ function stateOf(info, waits, stalled, running) {
33
39
  if (!info)
34
40
  return "gone";
35
41
  if (waits)
36
42
  return "waiting";
43
+ if (stalled)
44
+ return "stalled";
37
45
  return running ? "running" : "idle";
38
46
  }
39
47
  /** One session of the panel: what it is doing, its report and group standing, its last tool call and its cost. */
40
- async function row(ports, entry, depth) {
48
+ async function row(ports, { entry, depth, info, pending, forms, report }, stalled) {
41
49
  const { sessionID } = entry;
42
- const children = childStore(ports.storage);
43
- const [info, messages, pending, report, status, standing] = await Promise.all([
44
- ports.session.get({ sessionID }).catch(() => undefined),
50
+ const [messages, status, standing, stopped] = await Promise.all([
45
51
  ports.session.context({ sessionID }).catch(() => []),
46
- ports.pending(sessionID).catch(() => []),
47
- reportOf(children, sessionID).catch(() => undefined),
48
- lastStatus(children, sessionID).catch(() => undefined),
52
+ lastStatus(childStore(ports.storage), sessionID).catch(() => undefined),
49
53
  entry.group ? standingIn(ports, entry, entry.group) : undefined,
54
+ stoppedSincePrompt(childStore(ports.storage), sessionID).catch(() => false),
50
55
  ]);
51
56
  const running = info !== undefined && busy(info.time);
52
57
  const tool = lastTool(messages);
@@ -55,8 +60,10 @@ async function row(ports, entry, depth) {
55
60
  parentID: entry.parentID,
56
61
  title: info?.title ?? entry.title,
57
62
  depth,
58
- state: stateOf(info, pending.length, running),
63
+ state: stateOf(info, pending.length + forms.length, stalled !== undefined, running),
64
+ ...(stalled !== undefined ? { stalled } : {}),
59
65
  ...(info?.outcome ? { outcome: info.outcome } : {}),
66
+ ...(stopped ? { stopped } : {}),
60
67
  ...reportRow(report, status),
61
68
  ...(entry.group && standing ? { group: { name: entry.group, standing } } : {}),
62
69
  isolated: entry.isolated,
@@ -64,12 +71,17 @@ async function row(ports, entry, depth) {
64
71
  ...(info && !running && info.time.idle !== undefined ? { idle: info.time.idle } : {}),
65
72
  ...(info ? { cost: info.cost } : {}),
66
73
  ...(tool ? { tool } : {}),
67
- waits: pending.map(waitOf),
74
+ waits: [...pending.map(waitOf), ...forms.map(formWait)],
68
75
  };
69
76
  }
70
- /** The tree panel's rows: the sessions courier_tree reads under a session, at most `maxTotal`, each as `row` reads it. */
77
+ /**
78
+ * The tree panel's rows: the sessions under a session as courier_tree lists them, at most `maxTotal`, the live ones
79
+ * first, then the newest, each as `row` reads it; `truncated` counts the older ones left out, and `stalled` says the root is.
80
+ */
71
81
  export async function panel(ports, rootID) {
72
- const { nodes, truncated } = await subtree(ports.storage, rootID, ports.limits.maxTotal);
73
- const rows = await Promise.all(nodes.map(({ entry, depth }) => row(ports, entry, depth)));
74
- return { rows, ...(truncated ? { truncated: ports.limits.maxTotal } : {}) };
82
+ const { kept, leftOut } = await readTree(ports, rootID);
83
+ const stalled = await stalledAmong(ports, [rootID, ...kept.map((node) => node.entry.sessionID)]);
84
+ const rows = await Promise.all(kept.map((node) => row(ports, node, stalled.get(node.entry.sessionID))));
85
+ const own = stalled.get(rootID);
86
+ return { rows, ...(leftOut ? { truncated: leftOut } : {}), ...(own !== undefined ? { stalled: own } : {}) };
75
87
  }
@@ -80,6 +80,35 @@ export interface ChildStore {
80
80
  /** Removes report keys `reported` found, each best effort. */
81
81
  dropReported(keys: ReadonlyArray<string>): Promise<void>;
82
82
  }
83
+ /**
84
+ * A courier message waiting in a session's inbox, watched until it is delivered: `running` while the session runs a
85
+ * turn, which takes it at its next step; `idleAt` since the session was last seen idle with it waiting.
86
+ */
87
+ export interface Wake {
88
+ readonly sessionID: string;
89
+ readonly inboxID: string;
90
+ /** When OpenCode enqueued it. */
91
+ readonly at: number;
92
+ /** Its text and description, which waking the session again sends under the same inbox id. */
93
+ readonly text: string;
94
+ readonly description?: string;
95
+ readonly running: boolean;
96
+ readonly idleAt?: number;
97
+ /** When the scheduler woke the session again, the message still waiting. */
98
+ readonly rewoken?: number;
99
+ /** When the session was found still not woken after that: it is stalled. */
100
+ readonly stalled?: number;
101
+ }
102
+ export interface WakeStore {
103
+ /** Writes a watched message, replacing what was kept of it. */
104
+ put(wake: Wake): Promise<void>;
105
+ /** The messages watched in a session's inbox. */
106
+ of(sessionID: string): Promise<Wake[]>;
107
+ /** Every message watched. */
108
+ all(): Promise<Wake[]>;
109
+ /** Stops watching a message: delivered, cancelled, or its session gone. */
110
+ remove(sessionID: string, inboxID: string): Promise<void>;
111
+ }
83
112
  /** A member of a join group with where it is kept. */
84
113
  export interface Membership extends Member {
85
114
  readonly parentID: string;
@@ -133,6 +162,8 @@ export interface CourierPorts {
133
162
  readonly now: () => number;
134
163
  /** The permission requests and questions a session waits on, wherever they are pending. */
135
164
  readonly pending: (sessionID: string) => Promise<ReadonlyArray<Pending>>;
165
+ /** The forms a session shows, which only the person can answer: web search's, a plugin's; not the question tool's. */
166
+ readonly forms: (sessionID: string) => Promise<ReadonlyArray<ShownForm>>;
136
167
  readonly limits: Limits;
137
168
  /** The `nativeChildren` option: a child started without isolate is created with OpenCode's own `parentID`. */
138
169
  readonly nativeChildren?: boolean;
@@ -143,6 +174,11 @@ export interface CourierPorts {
143
174
  readonly nudge: () => void;
144
175
  readonly log: (message: string) => void;
145
176
  }
177
+ /** A form a session shows, as `CourierPorts.forms` lists it; `person/` keeps them. */
178
+ export interface ShownForm {
179
+ readonly id: string;
180
+ readonly title: string;
181
+ }
146
182
  /** One question a session waits on, as `Pending` shows it; `person/`'s `Prompt` has these fields. */
147
183
  export interface PendingPrompt {
148
184
  readonly question: string;
@@ -18,6 +18,11 @@ export declare function lineage(storage: RosterStorage, sessionID: string): Prom
18
18
  export declare function answeringTop(storage: RosterStorage, sessionID: string, callerID: string, what: string): Promise<string>;
19
19
  /** Whether a directory still exists; tests pass a fake. */
20
20
  export type Exists = (directory: string) => boolean;
21
+ /**
22
+ * Whether an entry is past retention at `now`: recorded more than `RETENTION_MS` before, unless it is an isolated
23
+ * child whose worktree exists, which is kept so it can still be cleaned up.
24
+ */
25
+ export declare const isExpired: (entry: RosterEntry, now: number, exists?: Exists) => boolean;
21
26
  /** A parent's children, oldest first, after dropping the expired ones. */
22
27
  export declare function current(storage: RosterStorage, parentID: string, now: number, exists?: Exists): Promise<RosterEntry[]>;
23
28
  /**
@@ -31,11 +31,13 @@ export async function answeringTop(storage, sessionID, callerID, what) {
31
31
  return top;
32
32
  }
33
33
  /**
34
- * Forgets the given entries that were recorded more than `RETENTION_MS` before `now`, and returns
35
- * the rest. An isolated child is kept while its worktree exists, so it can still be cleaned up.
34
+ * Whether an entry is past retention at `now`: recorded more than `RETENTION_MS` before, unless it is an isolated
35
+ * child whose worktree exists, which is kept so it can still be cleaned up.
36
36
  */
37
+ export const isExpired = (entry, now, exists = existsSync) => now - entry.createdAt > RETENTION_MS && !(entry.isolated && exists(entry.directory));
38
+ /** Forgets the given entries that are expired at `now`, and returns the rest. */
37
39
  async function dropExpired(storage, entries, now, exists) {
38
- const expired = new Set(entries.filter((entry) => now - entry.createdAt > RETENTION_MS && !(entry.isolated && exists(entry.directory))));
40
+ const expired = new Set(entries.filter((entry) => isExpired(entry, now, exists)));
39
41
  await Promise.all([...expired].map((entry) => forgetChild(storage, entry.parentID, entry.sessionID, entry.group)));
40
42
  return entries.filter((entry) => !expired.has(entry));
41
43
  }