opencode-courier 0.6.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 CHANGED
@@ -14,10 +14,9 @@ polling.
14
14
  [![License: MIT](https://img.shields.io/npm/l/opencode-courier)](LICENSE)
15
15
  [![Supported OpenCode V2 version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fivopogace%2Fopencode-courier%2Fmain%2Fpackage.json&query=%24.peerDependencies%5B%27%40opencode%2Fplugin%27%5D&label=opencode&color=blue)](#supported-opencode-version)
16
16
 
17
- ![A parent spawns two slow children, writes a README itself, and is woken by each report](docs/demo-async-tui.gif)
17
+ https://github.com/user-attachments/assets/74a50b9c-462a-43f5-8737-bc7dd1917988
18
18
 
19
- A parent writes a README while two slow children work, and each report wakes it ([the
20
- prompt](#demos)).
19
+ The edge cases on the live terminal UI, narrated ([what it shows](#demos)).
21
20
 
22
21
  ## Quickstart
23
22
 
@@ -80,21 +79,18 @@ idle); `queue: true` waits until the current turn ends.
80
79
 
81
80
  ## Demos
82
81
 
83
- **The parent does not wait.** Prompt: *"Spawn two children: one runs `sleep 30` then lists the
84
- exports of math.js, the other runs `sleep 45` then lists the exports of text.js. Don't wait for
85
- them: meanwhile write a short README.md for this folder yourself, and add their reports to it when
86
- they arrive."* The recording at the top is this run: the parent writes its README and finishes its
87
- turn 16 seconds in, while both children still sleep; each report (the `Message from ses_…` line)
88
- then starts a new turn on its own, and the parent folds it into the README.
82
+ The video at the top (1:46, narrated) is the TUI driven in `tmux` on the scripted stand-in model of
83
+ the live test, with `e2e/tui.sh`'s setup, so each scene is real plugin behaviour:
89
84
 
90
- **A child's question reaches you.** Prompt: *"Spawn a child session to add a multiply function to
91
- math.js. It must ask me first whether to name it multiply, times or product."*
92
-
93
- ![A child's question opens as a form in the parent's session, times is picked, and the child carries on](docs/demo-question-tui.gif)
94
-
95
- The child's question opens in the parent's session as OpenCode's own question form, `times` is
96
- picked, and the child adds `times` to math.js and reports. Both recordings are real runs with a free
97
- model on [OpenCode Zen](https://opencode.ai/zen) (Muse Spark 1.3).
85
+ - **A join group:** one member reports and its report is held with the group, while the other waits
86
+ on a permission request, so the footer says one is waiting on you. You allow it from the asks inbox
87
+ (`ctrl+x k`), and both reports reach the parent in one message.
88
+ - **A child whose turn fails:** OpenCode shows "Session failed", the parent is told, and the
89
+ `/courier tree` row reads `failed`.
90
+ - **A child that ends its turn without a report:** the parent is told, and the row says so.
91
+ - **`/courier stop`:** pick the session, confirm, and it is stopped.
92
+ - **A form only you can answer:** a child's web search shows a form; the footer, the panel row and
93
+ the asks inbox point to it, and answering it there wakes the parent.
98
94
 
99
95
  ## Recursive orchestration
100
96
 
@@ -117,7 +113,7 @@ line to the server log naming both ([more](docs/reference.md#the-opencode-versio
117
113
 
118
114
  | opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
119
115
  |---|---|
120
- | 0.5.0, 0.6.0 | 2.0.29 |
116
+ | 0.5.0, 0.6.0, 0.7.0 | 2.0.29 |
121
117
  | 0.4.0, 0.4.1 | 2.0.28 |
122
118
  | 0.2.3, 0.3.0 | 2.0.26 |
123
119
  | 0.2.2 | 2.0.24 |
@@ -213,6 +209,11 @@ Each tool's parameters: [the reference](docs/reference.md#the-tools).
213
209
  plugin, with the error or its last reply, waking it if idle; one that waits on a request, its own
214
210
  children, a scheduled message or a webhook is not, nor is one that answers what you typed in its
215
211
  session. [More](docs/reference.md#a-child-that-fails).
212
+ - **A message that fails to wake its session is sent again.** A report, notice or scheduled message
213
+ that waits in an idle session's inbox for 30 seconds without a turn starting for it is sent once
214
+ more under the same inbox id, which OpenCode takes for the same message, not a second one. If the
215
+ session still does not take it, the footer and the tree panel show it as `stalled`, for you to
216
+ prompt it. [More](docs/reference.md#a-missed-wake).
216
217
  - **Permission requests and questions reach you.** A child's request or question is passed to the
217
218
  session at the top of its tree, any number of levels down. For a request, that session proposes an
218
219
  answer with `courier_answer`, and OpenCode asks you in its own permission prompt, in that session
@@ -257,8 +258,9 @@ does not. Each spawned session's brief says when to split its task and when to d
257
258
 
258
259
  The package ships a part for OpenCode's terminal UI, which the TUI loads from the server's plugin
259
260
  list; the server half behaves the same whether or not a TUI is attached. In a session that started
260
- sessions, the prompt's footer shows what the tree under it is doing: how many run, wait on you (a
261
- permission request, a question or a form they show) or are idle, and for each [join group](docs/reference.md#join-groups) how many of its members have
261
+ sessions, the prompt's footer shows what the tree under it is doing: how many wait on you (a
262
+ permission request, a question or a form they show), are [stalled](docs/reference.md#a-missed-wake),
263
+ run or are idle, and for each [join group](docs/reference.md#join-groups) how many of its members have
262
264
  reported:
263
265
 
264
266
  ```
@@ -267,7 +269,7 @@ courier 1 waiting on you · 2 running · group pair 1/2
267
269
 
268
270
  `/courier tree` opens a panel beside the session with the tree under it, a row per session indented
269
271
  by depth, with what it does, its report and its group's standing; a session waiting on you gets a line per
270
- request or form (`shows a form: Web Search`), a session that started others gets
272
+ request or form (`shows a form: Web Search`), a stalled one since when its message waits, a session that started others gets
271
273
  a bar of the parts reported, a leaf how long it ran, its last tool call and its cost. A tree that has
272
274
  started more than `maxTotal` sessions over time is listed live ones first, then newest, and the header
273
275
  says how many older ones are left out; the footer counts them all. `↑`/`↓` select,
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { builtVersions, versionNotice } from "./platform/version.js";
9
9
  import { addSchedulingTools, cancelScheduled, dueJob, isScheduled } from "./scheduling/index.js";
10
10
  import { addPersonTools, asksPermission, asksQuestion, formsShown, joinRelay, noticeCutOff, pendingOf, pendingQuestions, personEvents, portsAt, registerPersonRpc, relayQuestions, } from "./person/index.js";
11
11
  import { addSkill } from "./skill.js";
12
- import { addTreeTools, deliverReleased, dirtyOf, headOf, inspectWorktree, pruneExpired, readLimits, readNativeChildren, registerTreeRpc, shapeContext, together, treeEvents, waitsOn, } from "./tree/index.js";
12
+ import { addTreeTools, deliverReleased, wakeMissed, dirtyOf, headOf, inspectWorktree, pruneExpired, readLimits, readNativeChildren, registerTreeRpc, shapeContext, together, treeEvents, waitsOn, } from "./tree/index.js";
13
13
  import { addWebhookTools, joinReceiver, readConfig, receiving, subscribedSince, } from "./webhook/index.js";
14
14
  /** The spawns under way in the process, outside the hub, so spawns in other locations, copies and hub versions count them. */
15
15
  const gate = sharedState("spawning", () => ({ reserved: new Set(), turn: Promise.resolve() }));
@@ -19,11 +19,12 @@ const inbox = sharedState("inbox", () => new Map());
19
19
  const shown = sharedState("formsShown", () => new Map());
20
20
  /**
21
21
  * What each tick of the scheduler does, in order, through the first loaded member, of whichever copy:
22
- * due `courier_later` messages, then released join groups. Every hub-v2 member's `later` is its LaterPorts.
22
+ * due `courier_later` messages, released join groups, then missed wakes. Every hub-v2 member's `later` is its LaterPorts.
23
23
  */
24
24
  const jobs = [
25
25
  dueJob,
26
26
  (owner, claimed) => deliverReleased(owner.later, claimed),
27
+ (owner, claimed) => wakeMissed(owner.later, claimed),
27
28
  ];
28
29
  const { join, track, nudge } = openInProcess(jobs);
29
30
  /** The permission domains of the loaded instances: every member's is its location's, whichever copy joined it. */
@@ -6,6 +6,7 @@ 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.
@@ -193,13 +194,37 @@ function routeTree(ports, state, outside, event) {
193
194
  return noteDeleted(ports, event);
194
195
  return undefined;
195
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
+ }
196
221
  /**
197
222
  * Tells parents of a child's failed or silent turn, unless it waits on what `outside` says, and notes
198
223
  * its prompts, interruptions and deletion; `after` a turn's end, seals the groups of its session.
199
224
  */
200
225
  export function treeEvents(state, outside) {
201
226
  return {
202
- handle: (ports, event) => routeTree(ports, state, outside, event),
227
+ handle: (ports, event) => route(ports, state, outside, event),
203
228
  // After: a silent end is judged while the group the nudge may deliver is still there to wait on.
204
229
  after: (ports, event) => (TURN_ENDS.has(event.type) ? sealGroups(ports, event) : undefined),
205
230
  };
@@ -8,3 +8,4 @@ export { allEntries, answeringTop, lineage, pruneExpired, RETENTION_MS } from ".
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";
@@ -12,7 +12,7 @@ export declare const waitOf: (pending: Pending) => Wait;
12
12
  export declare const formWait: (form: ShownForm) => Wait;
13
13
  /**
14
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.
15
+ * first, then the newest, each as `row` reads it; `truncated` counts the older ones left out, and `stalled` says the root is.
16
16
  */
17
17
  export declare function panel(ports: CourierPorts, rootID: string): Promise<Tree>;
18
18
  export {};
@@ -3,6 +3,7 @@ import { lastStatus, stoppedSincePrompt } from "./report.js";
3
3
  import { standingIn } from "./status.js";
4
4
  import { childStore } from "./store.js";
5
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()) {
@@ -30,16 +31,21 @@ function reportRow(report, status) {
30
31
  return { report: { state: "ended", progressed: report.progressed } };
31
32
  return { report: { state: "reported", progressed: report.progressed, ...(status ? { status } : {}) } };
32
33
  }
33
- /** What a session does: waiting on a request to the person or a form it shows them, running, idle, or gone when OpenCode no longer knows it. */
34
- 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) {
35
39
  if (!info)
36
40
  return "gone";
37
41
  if (waits)
38
42
  return "waiting";
43
+ if (stalled)
44
+ return "stalled";
39
45
  return running ? "running" : "idle";
40
46
  }
41
47
  /** One session of the panel: what it is doing, its report and group standing, its last tool call and its cost. */
42
- async function row(ports, { entry, depth, info, pending, forms, report }) {
48
+ async function row(ports, { entry, depth, info, pending, forms, report }, stalled) {
43
49
  const { sessionID } = entry;
44
50
  const [messages, status, standing, stopped] = await Promise.all([
45
51
  ports.session.context({ sessionID }).catch(() => []),
@@ -54,7 +60,8 @@ async function row(ports, { entry, depth, info, pending, forms, report }) {
54
60
  parentID: entry.parentID,
55
61
  title: info?.title ?? entry.title,
56
62
  depth,
57
- state: stateOf(info, pending.length + forms.length, running),
63
+ state: stateOf(info, pending.length + forms.length, stalled !== undefined, running),
64
+ ...(stalled !== undefined ? { stalled } : {}),
58
65
  ...(info?.outcome ? { outcome: info.outcome } : {}),
59
66
  ...(stopped ? { stopped } : {}),
60
67
  ...reportRow(report, status),
@@ -69,10 +76,12 @@ async function row(ports, { entry, depth, info, pending, forms, report }) {
69
76
  }
70
77
  /**
71
78
  * The tree panel's rows: the sessions under a session as courier_tree lists them, at most `maxTotal`, the live ones
72
- * first, then the newest, each as `row` reads it; `truncated` counts the older ones left out.
79
+ * first, then the newest, each as `row` reads it; `truncated` counts the older ones left out, and `stalled` says the root is.
73
80
  */
74
81
  export async function panel(ports, rootID) {
75
82
  const { kept, leftOut } = await readTree(ports, rootID);
76
- const rows = await Promise.all(kept.map((node) => row(ports, node)));
77
- return { rows, ...(leftOut ? { truncated: leftOut } : {}) };
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 } : {}) };
78
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;
@@ -14,26 +14,29 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
14
14
  }>;
15
15
  readonly output: import("effect/StandardSchema").StandardSchemaV1<{
16
16
  readonly running: number;
17
- readonly waiting: number;
18
17
  readonly idle: number;
19
18
  readonly groups: readonly {
20
19
  readonly name: string;
21
20
  readonly reported: number;
22
21
  readonly members: number;
23
22
  }[];
23
+ readonly waiting: number;
24
+ readonly stalled?: number | undefined;
24
25
  }, {
25
26
  readonly running: number;
26
- readonly waiting: number;
27
27
  readonly idle: number;
28
28
  readonly groups: readonly {
29
29
  readonly name: string;
30
30
  readonly reported: number;
31
31
  readonly members: number;
32
32
  }[];
33
+ readonly waiting: number;
34
+ readonly stalled?: number | undefined;
33
35
  }> & import("effect/Schema").Struct<{
34
36
  readonly running: import("effect/Schema").Int;
35
37
  readonly waiting: import("effect/Schema").Int;
36
38
  readonly idle: import("effect/Schema").Int;
39
+ readonly stalled: import("effect/Schema").optionalKey<import("effect/Schema").Int>;
37
40
  readonly groups: import("effect/Schema").$Array<import("effect/Schema").Struct<{
38
41
  readonly name: import("effect/Schema").String;
39
42
  readonly reported: import("effect/Schema").Int;
@@ -57,7 +60,7 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
57
60
  readonly depth: number;
58
61
  readonly created: number;
59
62
  readonly isolated: boolean;
60
- readonly state: "running" | "idle" | "gone" | "waiting";
63
+ readonly state: "running" | "idle" | "gone" | "stalled" | "waiting";
61
64
  readonly waits: readonly ({
62
65
  readonly type: "permission";
63
66
  readonly action: string;
@@ -80,6 +83,7 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
80
83
  readonly state: "reported" | "ended" | "owed";
81
84
  readonly status?: string | undefined;
82
85
  } | undefined;
86
+ readonly stalled?: number | undefined;
83
87
  readonly outcome?: "failed" | "interrupted" | "succeeded" | undefined;
84
88
  readonly tool?: {
85
89
  readonly name: string;
@@ -87,6 +91,7 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
87
91
  } | undefined;
88
92
  readonly cost?: number | undefined;
89
93
  }[];
94
+ readonly stalled?: number | undefined;
90
95
  readonly truncated?: number | undefined;
91
96
  }, {
92
97
  readonly rows: readonly {
@@ -96,7 +101,7 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
96
101
  readonly depth: number;
97
102
  readonly created: number;
98
103
  readonly isolated: boolean;
99
- readonly state: "running" | "idle" | "gone" | "waiting";
104
+ readonly state: "running" | "idle" | "gone" | "stalled" | "waiting";
100
105
  readonly waits: readonly ({
101
106
  readonly type: "permission";
102
107
  readonly action: string;
@@ -119,6 +124,7 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
119
124
  readonly state: "reported" | "ended" | "owed";
120
125
  readonly status?: string | undefined;
121
126
  } | undefined;
127
+ readonly stalled?: number | undefined;
122
128
  readonly outcome?: "failed" | "interrupted" | "succeeded" | undefined;
123
129
  readonly tool?: {
124
130
  readonly name: string;
@@ -126,6 +132,7 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
126
132
  } | undefined;
127
133
  readonly cost?: number | undefined;
128
134
  }[];
135
+ readonly stalled?: number | undefined;
129
136
  readonly truncated?: number | undefined;
130
137
  }> & import("effect/Schema").Struct<{
131
138
  readonly rows: import("effect/Schema").$Array<import("effect/Schema").Struct<{
@@ -133,7 +140,8 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
133
140
  readonly parentID: import("effect/Schema").String;
134
141
  readonly title: import("effect/Schema").String;
135
142
  readonly depth: import("effect/Schema").Int;
136
- readonly state: import("effect/Schema").Literals<readonly ["running", "waiting", "idle", "gone"]>;
143
+ readonly state: import("effect/Schema").Literals<readonly ["running", "waiting", "idle", "stalled", "gone"]>;
144
+ readonly stalled: import("effect/Schema").optionalKey<import("effect/Schema").Number>;
137
145
  readonly outcome: import("effect/Schema").optionalKey<import("effect/Schema").Literals<readonly ["succeeded", "failed", "interrupted"]>>;
138
146
  readonly stopped: import("effect/Schema").optionalKey<import("effect/Schema").Boolean>;
139
147
  readonly report: import("effect/Schema").optionalKey<import("effect/Schema").Struct<{
@@ -166,6 +174,7 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
166
174
  }>]>>;
167
175
  }>>;
168
176
  readonly truncated: import("effect/Schema").optionalKey<import("effect/Schema").Int>;
177
+ readonly stalled: import("effect/Schema").optionalKey<import("effect/Schema").Number>;
169
178
  }>;
170
179
  };
171
180
  readonly stop: {
@@ -7,6 +7,8 @@ declare const Summary: Schema.Struct<{
7
7
  readonly waiting: Schema.Int;
8
8
  /** Not running: done, or waiting for a message. */
9
9
  readonly idle: Schema.Int;
10
+ /** Left a courier message waiting while idle, and did not take it once woken again; the session itself counts too. Absent at 0. */
11
+ readonly stalled: Schema.optionalKey<Schema.Int>;
10
12
  readonly groups: Schema.$Array<Schema.Struct<{
11
13
  readonly name: Schema.String;
12
14
  readonly reported: Schema.Int;
@@ -33,8 +35,13 @@ declare const Row: Schema.Struct<{
33
35
  readonly title: Schema.String;
34
36
  /** A child of the panel's session is at depth 1. */
35
37
  readonly depth: Schema.Int;
36
- /** `waiting` on a request to the person or a form it shows them; `gone` when OpenCode no longer knows it. */
37
- readonly state: Schema.Literals<readonly ["running", "waiting", "idle", "gone"]>;
38
+ /**
39
+ * `waiting` on a request to the person or a form it shows them; `stalled` when a courier message waits in its inbox and
40
+ * waking it again did not start a turn; `gone` when OpenCode no longer knows it.
41
+ */
42
+ readonly state: Schema.Literals<readonly ["running", "waiting", "idle", "stalled", "gone"]>;
43
+ /** Since when its oldest message that did not wake it has waited, when it is stalled. */
44
+ readonly stalled: Schema.optionalKey<Schema.Number>;
38
45
  /** How its last turn ended. */
39
46
  readonly outcome: Schema.optionalKey<Schema.Literals<readonly ["succeeded", "failed", "interrupted"]>>;
40
47
  /** Set when courier_stop stopped it since its last prompt. */
@@ -81,8 +88,13 @@ declare const Tree: Schema.Struct<{
81
88
  readonly title: Schema.String;
82
89
  /** A child of the panel's session is at depth 1. */
83
90
  readonly depth: Schema.Int;
84
- /** `waiting` on a request to the person or a form it shows them; `gone` when OpenCode no longer knows it. */
85
- readonly state: Schema.Literals<readonly ["running", "waiting", "idle", "gone"]>;
91
+ /**
92
+ * `waiting` on a request to the person or a form it shows them; `stalled` when a courier message waits in its inbox and
93
+ * waking it again did not start a turn; `gone` when OpenCode no longer knows it.
94
+ */
95
+ readonly state: Schema.Literals<readonly ["running", "waiting", "idle", "stalled", "gone"]>;
96
+ /** Since when its oldest message that did not wake it has waited, when it is stalled. */
97
+ readonly stalled: Schema.optionalKey<Schema.Number>;
86
98
  /** How its last turn ended. */
87
99
  readonly outcome: Schema.optionalKey<Schema.Literals<readonly ["succeeded", "failed", "interrupted"]>>;
88
100
  /** Set when courier_stop stopped it since its last prompt. */
@@ -123,6 +135,8 @@ declare const Tree: Schema.Struct<{
123
135
  }>>;
124
136
  /** How many older sessions the listing left out, when it had more than `maxTotal`: the live ones come first, then the newest. */
125
137
  readonly truncated: Schema.optionalKey<Schema.Int>;
138
+ /** Since when a courier message has waited in the panel's own session, when that session is stalled. */
139
+ readonly stalled: Schema.optionalKey<Schema.Number>;
126
140
  }>;
127
141
  export type Row = typeof Row.Type;
128
142
  export type Tree = typeof Tree.Type;
@@ -159,22 +173,24 @@ export declare const treeRpc: {
159
173
  }>;
160
174
  readonly output: import("effect/StandardSchema").StandardSchemaV1<{
161
175
  readonly running: number;
162
- readonly waiting: number;
163
176
  readonly idle: number;
164
177
  readonly groups: readonly {
165
178
  readonly name: string;
166
179
  readonly reported: number;
167
180
  readonly members: number;
168
181
  }[];
182
+ readonly waiting: number;
183
+ readonly stalled?: number | undefined;
169
184
  }, {
170
185
  readonly running: number;
171
- readonly waiting: number;
172
186
  readonly idle: number;
173
187
  readonly groups: readonly {
174
188
  readonly name: string;
175
189
  readonly reported: number;
176
190
  readonly members: number;
177
191
  }[];
192
+ readonly waiting: number;
193
+ readonly stalled?: number | undefined;
178
194
  }> & Schema.Struct<{
179
195
  /** Running, and waiting on no request or form. */
180
196
  readonly running: Schema.Int;
@@ -182,6 +198,8 @@ export declare const treeRpc: {
182
198
  readonly waiting: Schema.Int;
183
199
  /** Not running: done, or waiting for a message. */
184
200
  readonly idle: Schema.Int;
201
+ /** Left a courier message waiting while idle, and did not take it once woken again; the session itself counts too. Absent at 0. */
202
+ readonly stalled: Schema.optionalKey<Schema.Int>;
185
203
  readonly groups: Schema.$Array<Schema.Struct<{
186
204
  readonly name: Schema.String;
187
205
  readonly reported: Schema.Int;
@@ -205,7 +223,7 @@ export declare const treeRpc: {
205
223
  readonly depth: number;
206
224
  readonly created: number;
207
225
  readonly isolated: boolean;
208
- readonly state: "running" | "idle" | "gone" | "waiting";
226
+ readonly state: "running" | "idle" | "gone" | "stalled" | "waiting";
209
227
  readonly waits: readonly ({
210
228
  readonly type: "permission";
211
229
  readonly action: string;
@@ -228,6 +246,7 @@ export declare const treeRpc: {
228
246
  readonly state: "reported" | "ended" | "owed";
229
247
  readonly status?: string | undefined;
230
248
  } | undefined;
249
+ readonly stalled?: number | undefined;
231
250
  readonly outcome?: "failed" | "interrupted" | "succeeded" | undefined;
232
251
  readonly tool?: {
233
252
  readonly name: string;
@@ -235,6 +254,7 @@ export declare const treeRpc: {
235
254
  } | undefined;
236
255
  readonly cost?: number | undefined;
237
256
  }[];
257
+ readonly stalled?: number | undefined;
238
258
  readonly truncated?: number | undefined;
239
259
  }, {
240
260
  readonly rows: readonly {
@@ -244,7 +264,7 @@ export declare const treeRpc: {
244
264
  readonly depth: number;
245
265
  readonly created: number;
246
266
  readonly isolated: boolean;
247
- readonly state: "running" | "idle" | "gone" | "waiting";
267
+ readonly state: "running" | "idle" | "gone" | "stalled" | "waiting";
248
268
  readonly waits: readonly ({
249
269
  readonly type: "permission";
250
270
  readonly action: string;
@@ -267,6 +287,7 @@ export declare const treeRpc: {
267
287
  readonly state: "reported" | "ended" | "owed";
268
288
  readonly status?: string | undefined;
269
289
  } | undefined;
290
+ readonly stalled?: number | undefined;
270
291
  readonly outcome?: "failed" | "interrupted" | "succeeded" | undefined;
271
292
  readonly tool?: {
272
293
  readonly name: string;
@@ -274,6 +295,7 @@ export declare const treeRpc: {
274
295
  } | undefined;
275
296
  readonly cost?: number | undefined;
276
297
  }[];
298
+ readonly stalled?: number | undefined;
277
299
  readonly truncated?: number | undefined;
278
300
  }> & Schema.Struct<{
279
301
  readonly rows: Schema.$Array<Schema.Struct<{
@@ -282,8 +304,13 @@ export declare const treeRpc: {
282
304
  readonly title: Schema.String;
283
305
  /** A child of the panel's session is at depth 1. */
284
306
  readonly depth: Schema.Int;
285
- /** `waiting` on a request to the person or a form it shows them; `gone` when OpenCode no longer knows it. */
286
- readonly state: Schema.Literals<readonly ["running", "waiting", "idle", "gone"]>;
307
+ /**
308
+ * `waiting` on a request to the person or a form it shows them; `stalled` when a courier message waits in its inbox and
309
+ * waking it again did not start a turn; `gone` when OpenCode no longer knows it.
310
+ */
311
+ readonly state: Schema.Literals<readonly ["running", "waiting", "idle", "stalled", "gone"]>;
312
+ /** Since when its oldest message that did not wake it has waited, when it is stalled. */
313
+ readonly stalled: Schema.optionalKey<Schema.Number>;
287
314
  /** How its last turn ended. */
288
315
  readonly outcome: Schema.optionalKey<Schema.Literals<readonly ["succeeded", "failed", "interrupted"]>>;
289
316
  /** Set when courier_stop stopped it since its last prompt. */
@@ -324,6 +351,8 @@ export declare const treeRpc: {
324
351
  }>>;
325
352
  /** How many older sessions the listing left out, when it had more than `maxTotal`: the live ones come first, then the newest. */
326
353
  readonly truncated: Schema.optionalKey<Schema.Int>;
354
+ /** Since when a courier message has waited in the panel's own session, when that session is stalled. */
355
+ readonly stalled: Schema.optionalKey<Schema.Number>;
327
356
  }>;
328
357
  };
329
358
  /** Stops `sessionID` and everything under it, as courier_stop called from `by`, which must be above it. */
package/dist/tree/rpc.js CHANGED
@@ -15,6 +15,8 @@ const Summary = Schema.Struct({
15
15
  waiting: Count,
16
16
  /** Not running: done, or waiting for a message. */
17
17
  idle: Count,
18
+ /** Left a courier message waiting while idle, and did not take it once woken again; the session itself counts too. Absent at 0. */
19
+ stalled: Schema.optionalKey(Count),
18
20
  groups: Schema.Array(Group),
19
21
  });
20
22
  /** A request a session waits on until the person answers it: a permission request, its first question, or a form it shows. */
@@ -30,8 +32,13 @@ const Row = Schema.Struct({
30
32
  title: Schema.String,
31
33
  /** A child of the panel's session is at depth 1. */
32
34
  depth: Count,
33
- /** `waiting` on a request to the person or a form it shows them; `gone` when OpenCode no longer knows it. */
34
- state: Schema.Literals(["running", "waiting", "idle", "gone"]),
35
+ /**
36
+ * `waiting` on a request to the person or a form it shows them; `stalled` when a courier message waits in its inbox and
37
+ * waking it again did not start a turn; `gone` when OpenCode no longer knows it.
38
+ */
39
+ state: Schema.Literals(["running", "waiting", "idle", "stalled", "gone"]),
40
+ /** Since when its oldest message that did not wake it has waited, when it is stalled. */
41
+ stalled: Schema.optionalKey(Schema.Number),
35
42
  /** How its last turn ended. */
36
43
  outcome: Schema.optionalKey(Schema.Literals(["succeeded", "failed", "interrupted"])),
37
44
  /** Set when courier_stop stopped it since its last prompt. */
@@ -55,6 +62,8 @@ const Tree = Schema.Struct({
55
62
  rows: Schema.Array(Row),
56
63
  /** How many older sessions the listing left out, when it had more than `maxTotal`: the live ones come first, then the newest. */
57
64
  truncated: Schema.optionalKey(Count),
65
+ /** Since when a courier message has waited in the panel's own session, when that session is stalled. */
66
+ stalled: Schema.optionalKey(Schema.Number),
58
67
  });
59
68
  /** What a stop did to each session, deepest first, as courier_stop reports it. */
60
69
  const Stopped = Schema.Struct({
package/dist/tree/stop.js CHANGED
@@ -4,6 +4,7 @@ import { cleanup } from "./cleanup.js";
4
4
  import { childStore } from "./store.js";
5
5
  import { subtree } from "./subtree.js";
6
6
  import { stoppedNotice } from "./text.js";
7
+ import { forgetWakes } from "./wake.js";
7
8
  /** Rounds of looking again for sessions started while others were being stopped. */
8
9
  const ROUNDS = 3;
9
10
  /**
@@ -62,6 +63,7 @@ async function silence(ports, sessionID, cancelled) {
62
63
  catch (error) {
63
64
  log(`courier_stop: could not cancel the messages scheduled for ${sessionID}: ${String(error)}`);
64
65
  }
66
+ await forgetWakes(ports.courier, sessionID).catch((error) => log(`courier_stop: could not drop the wake watch of ${sessionID}: ${String(error)}`));
65
67
  }
66
68
  async function interrupt(ports, { entry, depth }) {
67
69
  const { sessionID, title } = entry;
@@ -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;
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  import type { CourierPorts } from "./ports.js";
2
2
  import type { Summary } from "./rpc.js";
3
3
  /**
4
- * Every session started under a session, counted by what it is doing, and its open join groups, those of its own
5
- * children, with how far each has reported.
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>;
@@ -2,26 +2,35 @@ import { busy } from "./domain.js";
2
2
  import { groupStandings } from "./group.js";
3
3
  import { groupStore } from "./store.js";
4
4
  import { lookUp } from "./subtree.js";
5
- /** What a session is doing: waiting on a request it made or a form it shows, running, or idle; undefined when OpenCode no longer knows it. */
6
- function activity({ info, pending, forms }) {
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) {
7
11
  if (!info)
8
12
  return undefined;
9
13
  if (pending.length || forms.length)
10
14
  return "waiting";
15
+ if (stalled.has(entry.sessionID))
16
+ return "stalled";
11
17
  return busy(info.time) ? "running" : "idle";
12
18
  }
13
19
  /**
14
- * Every session started under a session, counted by what it is doing, and its open join groups, those of its own
15
- * children, with how far each has reported.
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.
16
22
  */
17
23
  export async function summary(ports, sessionID) {
18
24
  const [looked, standings] = await Promise.all([lookUp(ports, sessionID), groupStandings(groupStore(ports.storage), sessionID)]);
19
- const activities = looked.map(activity);
25
+ const stalled = await stalledAmong(ports, [sessionID, ...looked.map((one) => one.entry.sessionID)]);
26
+ const activities = looked.map((one) => activity(one, stalled));
20
27
  const count = (kind) => activities.filter((item) => item === kind).length;
28
+ const stalls = count("stalled") + (stalled.has(sessionID) ? 1 : 0);
21
29
  return {
22
30
  running: count("running"),
23
31
  waiting: count("waiting"),
24
32
  idle: count("idle"),
33
+ ...(stalls ? { stalled: stalls } : {}),
25
34
  groups: standings.map(({ name, reported, members }) => ({ name, reported, members })),
26
35
  };
27
36
  }
@@ -34,11 +34,15 @@ export declare function waitLine(wait: Wait): string;
34
34
  export declare function statusLine(row: Row): string;
35
35
  /** A leaf's activity: how long it has run, its last tool call and how long ago, and its cost. */
36
36
  export declare function activityLine(row: Row, now: number): string;
37
- /** The lines a row shows under its title: an orchestrator's bar, a paused session's requests, or else a live leaf's activity. */
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. */
38
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;
39
43
  /**
40
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
41
- * change; nothing on the home screen. Clicked while some wait on the person, it calls `onWaiting` with the open session.
45
+ * change and every `REREAD_MS`; nothing on the home screen. Clicked while some wait on the person, it calls `onWaiting`.
42
46
  */
43
47
  export declare function addTreeStatus(ctx: Context, onWaiting?: (sessionID: string) => void): () => void;
44
48
  /** The toast after a stop: how many sessions were interrupted, and how many scheduled messages were cancelled. */
@@ -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
+ }
package/dist/tui.js CHANGED
@@ -808,6 +808,7 @@ var Summary = Schema2.Struct({
808
808
  running: Count2,
809
809
  waiting: Count2,
810
810
  idle: Count2,
811
+ stalled: Schema2.optionalKey(Count2),
811
812
  groups: Schema2.Array(Group)
812
813
  });
813
814
  var Wait = Schema2.Union([
@@ -820,7 +821,8 @@ var Row = Schema2.Struct({
820
821
  parentID: Schema2.String,
821
822
  title: Schema2.String,
822
823
  depth: Count2,
823
- state: Schema2.Literals(["running", "waiting", "idle", "gone"]),
824
+ state: Schema2.Literals(["running", "waiting", "idle", "stalled", "gone"]),
825
+ stalled: Schema2.optionalKey(Schema2.Number),
824
826
  outcome: Schema2.optionalKey(Schema2.Literals(["succeeded", "failed", "interrupted"])),
825
827
  stopped: Schema2.optionalKey(Schema2.Boolean),
826
828
  report: Schema2.optionalKey(Schema2.Struct({ state: Schema2.Literals(["owed", "reported", "ended"]), status: Schema2.optionalKey(Schema2.String), progressed: Schema2.Boolean })),
@@ -834,7 +836,8 @@ var Row = Schema2.Struct({
834
836
  });
835
837
  var Tree = Schema2.Struct({
836
838
  rows: Schema2.Array(Row),
837
- truncated: Schema2.optionalKey(Count2)
839
+ truncated: Schema2.optionalKey(Count2),
840
+ stalled: Schema2.optionalKey(Schema2.Number)
838
841
  });
839
842
  var Stopped = Schema2.Struct({
840
843
  stopped: Schema2.Array(Schema2.Struct({ sessionID: Schema2.String, title: Schema2.String, outcome: Schema2.String })),
@@ -878,7 +881,7 @@ var PANEL = "courier-tree";
878
881
  var TICK_MS = 1000;
879
882
  function summaryLine(summary, maxWidth = Infinity) {
880
883
  const groups = summary.groups.map((group) => `group ${group.name} ${group.reported}/${group.members}`);
881
- const parts = [summary.waiting ? `${summary.waiting} waiting on you` : "", summary.running ? `${summary.running} running` : "", ...groups, summary.idle ? `${summary.idle} idle` : ""].filter(Boolean);
884
+ const parts = [summary.waiting ? `${summary.waiting} waiting on you` : "", summary.stalled ? `${summary.stalled} stalled` : "", summary.running ? `${summary.running} running` : "", ...groups, summary.idle ? `${summary.idle} idle` : ""].filter(Boolean);
882
885
  if (!parts.length)
883
886
  return "";
884
887
  const line = (kept) => {
@@ -924,6 +927,8 @@ function statusLine(row) {
924
927
  function stateWord(row) {
925
928
  if (row.state === "waiting")
926
929
  return "waiting on you";
930
+ if (row.state === "stalled")
931
+ return "stalled";
927
932
  if (row.state === "idle" && row.stopped)
928
933
  return "stopped";
929
934
  if (row.state === "idle" && (row.outcome === "failed" || row.outcome === "interrupted"))
@@ -943,10 +948,15 @@ var counted = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
943
948
  function activityLine(row, now) {
944
949
  return [`ran ${duration((row.idle ?? now) - row.created)}`, row.tool ? `last ${row.tool.name} ${duration(now - row.tool.at)} ago` : "no tool call yet", row.cost !== undefined ? `$${row.cost.toFixed(2)}` : ""].filter(Boolean).join(" \xB7 ");
945
950
  }
951
+ function stalledLine(since, now) {
952
+ return `a message waits in its inbox since ${duration(now - since)}, woken again in vain`;
953
+ }
946
954
  function detailLines(row, rows, now) {
947
955
  const started = parts(rows, row.sessionID);
948
956
  if (row.waits.length)
949
957
  return [...started.started ? [bar(started)] : [], ...row.waits.map(waitLine)];
958
+ if (row.stalled !== undefined)
959
+ return [...started.started ? [bar(started)] : [], stalledLine(row.stalled, now)];
950
960
  if (started.started)
951
961
  return [bar(started)];
952
962
  return row.state === "gone" ? [] : [activityLine(row, now)];
@@ -959,8 +969,10 @@ function courierOf(ctx) {
959
969
  location
960
970
  };
961
971
  }
972
+ var REREAD_MS = 15000;
962
973
  function changes(ctx, types, settle) {
963
974
  const [changed, setChanged] = createSignal(0);
975
+ const reread = setInterval(() => setChanged((count) => count + 1), REREAD_MS);
964
976
  let settling;
965
977
  const unlisten = ctx.data.listen(({
966
978
  details
@@ -974,6 +986,7 @@ function changes(ctx, types, settle) {
974
986
  });
975
987
  return [changed, () => {
976
988
  clearTimeout(settling);
989
+ clearInterval(reread);
977
990
  unlisten();
978
991
  }];
979
992
  }
@@ -1323,15 +1336,17 @@ function TreePanel(props) {
1323
1336
  }));
1324
1337
  const root = () => parts(rows(), view.root);
1325
1338
  const rowColor = (row) => {
1339
+ if (row.state === "stalled")
1340
+ return theme.text.feedback.error.base;
1326
1341
  if (row.state === "waiting")
1327
1342
  return theme.text.feedback.warning.base;
1328
1343
  return row.state === "gone" ? theme.text.muted : theme.text.base;
1329
1344
  };
1330
1345
  return (() => {
1331
- var _el$4 = _$createElement("box"), _el$5 = _$createElement("box"), _el$6 = _$createElement("text"), _el$13 = _$createElement("scrollbox"), _el$14 = _$createElement("text");
1346
+ var _el$4 = _$createElement("box"), _el$5 = _$createElement("box"), _el$6 = _$createElement("text"), _el$15 = _$createElement("scrollbox"), _el$16 = _$createElement("text");
1332
1347
  _$insertNode(_el$4, _el$5);
1333
- _$insertNode(_el$4, _el$13);
1334
- _$insertNode(_el$4, _el$14);
1348
+ _$insertNode(_el$4, _el$15);
1349
+ _$insertNode(_el$4, _el$16);
1335
1350
  _$setProp(_el$4, "flexDirection", "column");
1336
1351
  _$setProp(_el$4, "flexGrow", 1);
1337
1352
  _$setProp(_el$4, "paddingLeft", 1);
@@ -1378,21 +1393,33 @@ function TreePanel(props) {
1378
1393
  _$effect((_$p) => _$setProp(_el$1, "fg", theme.text.feedback.error.base, _$p));
1379
1394
  return _el$1;
1380
1395
  }
1381
- }), _el$13);
1396
+ }), _el$15);
1382
1397
  _$insert(_el$4, _$createComponent(Show, {
1383
1398
  get when() {
1384
- return _$memo(() => !!latest()?.tree)() && !rows().length;
1399
+ return latest()?.tree?.stalled;
1385
1400
  },
1386
1401
  get children() {
1387
- var _el$11 = _$createElement("text");
1388
- _$insertNode(_el$11, _$createTextNode(`No session started with courier_spawn under this one.`));
1389
- _$effect((_$p) => _$setProp(_el$11, "fg", theme.text.muted, _$p));
1402
+ var _el$11 = _$createElement("text"), _el$12 = _$createTextNode(`This session is stalled: `);
1403
+ _$insertNode(_el$11, _el$12);
1404
+ _$insert(_el$11, () => stalledLine(latest().tree.stalled, now()), null);
1405
+ _$effect((_$p) => _$setProp(_el$11, "fg", theme.text.feedback.error.base, _$p));
1390
1406
  return _el$11;
1391
1407
  }
1392
- }), _el$13);
1393
- _$use((value) => scroll = value, _el$13);
1394
- _$setProp(_el$13, "flexGrow", 1);
1395
- _$insert(_el$13, _$createComponent(For, {
1408
+ }), _el$15);
1409
+ _$insert(_el$4, _$createComponent(Show, {
1410
+ get when() {
1411
+ return _$memo(() => !!latest()?.tree)() && !rows().length;
1412
+ },
1413
+ get children() {
1414
+ var _el$13 = _$createElement("text");
1415
+ _$insertNode(_el$13, _$createTextNode(`No session started with courier_spawn under this one.`));
1416
+ _$effect((_$p) => _$setProp(_el$13, "fg", theme.text.muted, _$p));
1417
+ return _el$13;
1418
+ }
1419
+ }), _el$15);
1420
+ _$use((value) => scroll = value, _el$15);
1421
+ _$setProp(_el$15, "flexGrow", 1);
1422
+ _$insert(_el$15, _$createComponent(For, {
1396
1423
  get each() {
1397
1424
  return rows();
1398
1425
  },
@@ -1400,45 +1427,45 @@ function TreePanel(props) {
1400
1427
  const chosen = () => index() === selected();
1401
1428
  const indent = " ".repeat(row.depth - 1);
1402
1429
  return (() => {
1403
- var _el$16 = _$createElement("box"), _el$17 = _$createElement("box"), _el$18 = _$createElement("text"), _el$19 = _$createElement("text"), _el$20 = _$createTextNode(` `);
1404
- _$insertNode(_el$16, _el$17);
1405
- _$setProp(_el$16, "flexDirection", "column");
1406
- _$setProp(_el$16, "flexShrink", 0);
1407
- _$insertNode(_el$17, _el$18);
1408
- _$insertNode(_el$17, _el$19);
1409
- _$setProp(_el$17, "flexDirection", "row");
1410
- _$setProp(_el$17, "flexShrink", 0);
1411
- _$setProp(_el$18, "wrapMode", "none");
1412
- _$setProp(_el$18, "flexShrink", 1);
1413
- _$setProp(_el$18, "minWidth", 0);
1414
- _$insert(_el$18, indent, null);
1415
- _$insert(_el$18, () => chosen() ? "\u25B8 " : " ", null);
1416
- _$insert(_el$18, () => oneLine(row.title), null);
1417
- _$insert(_el$18, () => row.sessionID === input.sessionID ? " (on screen)" : "", null);
1430
+ var _el$18 = _$createElement("box"), _el$19 = _$createElement("box"), _el$20 = _$createElement("text"), _el$21 = _$createElement("text"), _el$22 = _$createTextNode(` `);
1431
+ _$insertNode(_el$18, _el$19);
1432
+ _$setProp(_el$18, "flexDirection", "column");
1433
+ _$setProp(_el$18, "flexShrink", 0);
1418
1434
  _$insertNode(_el$19, _el$20);
1419
- _$setProp(_el$19, "wrapMode", "none");
1435
+ _$insertNode(_el$19, _el$21);
1436
+ _$setProp(_el$19, "flexDirection", "row");
1420
1437
  _$setProp(_el$19, "flexShrink", 0);
1421
- _$insert(_el$19, () => statusLine(row), null);
1422
- _$insert(_el$16, _$createComponent(For, {
1438
+ _$setProp(_el$20, "wrapMode", "none");
1439
+ _$setProp(_el$20, "flexShrink", 1);
1440
+ _$setProp(_el$20, "minWidth", 0);
1441
+ _$insert(_el$20, indent, null);
1442
+ _$insert(_el$20, () => chosen() ? "\u25B8 " : " ", null);
1443
+ _$insert(_el$20, () => oneLine(row.title), null);
1444
+ _$insert(_el$20, () => row.sessionID === input.sessionID ? " (on screen)" : "", null);
1445
+ _$insertNode(_el$21, _el$22);
1446
+ _$setProp(_el$21, "wrapMode", "none");
1447
+ _$setProp(_el$21, "flexShrink", 0);
1448
+ _$insert(_el$21, () => statusLine(row), null);
1449
+ _$insert(_el$18, _$createComponent(For, {
1423
1450
  get each() {
1424
1451
  return detailLines(row, rows(), now());
1425
1452
  },
1426
1453
  children: (line) => (() => {
1427
- var _el$21 = _$createElement("text"), _el$22 = _$createTextNode(` `);
1428
- _$insertNode(_el$21, _el$22);
1429
- _$setProp(_el$21, "wrapMode", "none");
1430
- _$insert(_el$21, indent, _el$22);
1431
- _$insert(_el$21, line, null);
1432
- _$effect((_$p) => _$setProp(_el$21, "fg", theme.text.muted, _$p));
1433
- return _el$21;
1454
+ var _el$23 = _$createElement("text"), _el$24 = _$createTextNode(` `);
1455
+ _$insertNode(_el$23, _el$24);
1456
+ _$setProp(_el$23, "wrapMode", "none");
1457
+ _$insert(_el$23, indent, _el$24);
1458
+ _$insert(_el$23, line, null);
1459
+ _$effect((_$p) => _$setProp(_el$23, "fg", theme.text.muted, _$p));
1460
+ return _el$23;
1434
1461
  })()
1435
1462
  }), null);
1436
1463
  _$effect((_p$) => {
1437
1464
  var _v$3 = `courier-row-${index()}`, _v$4 = chosen() && input.focused ? theme.background.raised.high : undefined, _v$5 = rowColor(row), _v$6 = theme.text.muted;
1438
- _v$3 !== _p$.e && (_p$.e = _$setProp(_el$16, "id", _v$3, _p$.e));
1439
- _v$4 !== _p$.t && (_p$.t = _$setProp(_el$16, "backgroundColor", _v$4, _p$.t));
1440
- _v$5 !== _p$.a && (_p$.a = _$setProp(_el$18, "fg", _v$5, _p$.a));
1441
- _v$6 !== _p$.o && (_p$.o = _$setProp(_el$19, "fg", _v$6, _p$.o));
1465
+ _v$3 !== _p$.e && (_p$.e = _$setProp(_el$18, "id", _v$3, _p$.e));
1466
+ _v$4 !== _p$.t && (_p$.t = _$setProp(_el$18, "backgroundColor", _v$4, _p$.t));
1467
+ _v$5 !== _p$.a && (_p$.a = _$setProp(_el$20, "fg", _v$5, _p$.a));
1468
+ _v$6 !== _p$.o && (_p$.o = _$setProp(_el$21, "fg", _v$6, _p$.o));
1442
1469
  return _p$;
1443
1470
  }, {
1444
1471
  e: undefined,
@@ -1446,17 +1473,17 @@ function TreePanel(props) {
1446
1473
  a: undefined,
1447
1474
  o: undefined
1448
1475
  });
1449
- return _el$16;
1476
+ return _el$18;
1450
1477
  })();
1451
1478
  }
1452
1479
  }));
1453
- _$insertNode(_el$14, _$createTextNode(`\u2191\u2193 select \xB7 enter open \xB7 s stop \xB7 x forget \xB7 r refresh \xB7 f fullscreen \xB7 q close`));
1454
- _$setProp(_el$14, "wrapMode", "none");
1455
- _$setProp(_el$14, "flexShrink", 0);
1480
+ _$insertNode(_el$16, _$createTextNode(`\u2191\u2193 select \xB7 enter open \xB7 s stop \xB7 x forget \xB7 r refresh \xB7 f fullscreen \xB7 q close`));
1481
+ _$setProp(_el$16, "wrapMode", "none");
1482
+ _$setProp(_el$16, "flexShrink", 0);
1456
1483
  _$effect((_p$) => {
1457
1484
  var _v$ = theme.text.base, _v$2 = theme.text.muted;
1458
1485
  _v$ !== _p$.e && (_p$.e = _$setProp(_el$6, "fg", _v$, _p$.e));
1459
- _v$2 !== _p$.t && (_p$.t = _$setProp(_el$14, "fg", _v$2, _p$.t));
1486
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$16, "fg", _v$2, _p$.t));
1460
1487
  return _p$;
1461
1488
  }, {
1462
1489
  e: undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-courier",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Multi-session orchestration for OpenCode V2: spawn child sessions, be woken by them. No polling.",
5
5
  "license": "MIT",
6
6
  "author": "Ivo Pogace <71701666+ivopogace@users.noreply.github.com>",