opencode-courier 0.13.6 → 0.13.7

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
@@ -23,7 +23,7 @@ A narrated tour of the terminal UI: the tree panel, the asks inbox, failing chil
23
23
  ## Quickstart
24
24
 
25
25
  ```bash
26
- npm install -g @opencode/cli@2.2.4 # OpenCode V2, the version courier is tested on
26
+ npm install -g @opencode/cli@2.2.5 # OpenCode V2, the version courier is tested on
27
27
  opencode plugin add opencode-courier
28
28
  opencode
29
29
  ```
@@ -58,8 +58,9 @@ or stuck, the child calls `courier_send` with a status (`done`, `partial`, `bloc
58
58
  The message lands in the parent's inbox and starts a new turn if the parent is idle.
59
59
 
60
60
  Children started with the same `group` report together, in one message. A child that fails (a
61
- rate or quota limit with when it lifts), or ends its turn without reporting, is reported to its
62
- parent. A message that does not wake its session is sent once more, then shown as `stalled`. On
61
+ rate or quota limit with when it lifts) is reported to its parent. One about to end its turn
62
+ without reporting is reminded once, in the same turn, and its parent is told only if it still does
63
+ not report. A message that does not wake its session is sent once more, then shown as `stalled`. On
63
64
  OpenCode 2.2.0 a parent's system prompt carries one line per child with its state, kept current,
64
65
  so it need not look. Children, held reports and scheduled messages survive a restart. The `courier-orchestrate` skill, listed in every session, tells a model when to
65
66
  split a task and how to check and merge the parts. Every detail: [the reference](docs/reference.md).
@@ -67,10 +68,10 @@ split a task and how to check and merge the parts. Every detail: [the reference]
67
68
  ## Install
68
69
 
69
70
  Requires OpenCode V2: the `opencode` command from `@opencode/cli`. Each release is tested against
70
- one OpenCode version; this one is tested on **OpenCode 2.2.4** (check with `opencode --version`).
71
+ one OpenCode version; this one is tested on **OpenCode 2.2.5** (check with `opencode --version`).
71
72
 
72
73
  ```bash
73
- npm install -g @opencode/cli@2.2.4
74
+ npm install -g @opencode/cli@2.2.5
74
75
  opencode plugin add opencode-courier
75
76
  ```
76
77
 
@@ -83,13 +84,13 @@ name, without a version: OpenCode offers updates only for an entry without an ex
83
84
  <!-- compat-table:start rows=3: generated by script/compat-table.ts from the release tags -->
84
85
  | opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
85
86
  |---|---|
87
+ | 0.13.7 | 2.2.5 |
86
88
  | 0.13.6 | 2.2.4 |
87
89
  | 0.13.5 | 2.2.3 |
88
- | 0.13.2, 0.13.3, 0.13.4 | 2.2.2 |
89
90
  <!-- compat-table:end -->
90
91
 
91
92
  Every release: [the full table](docs/reference.md#the-opencode-version). The server part loads on
92
- any V2 host from 2.0.4 on. The terminal part is tested on OpenCode 2.2.4 only.
93
+ any V2 host from 2.0.4 on. The terminal part is tested on OpenCode 2.2.5 only.
93
94
 
94
95
  ## Options
95
96
 
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import { addSchedulingTools, cancelScheduled, dueJob, isScheduled } from "./sche
10
10
  import { addPersonTools, asksPermission, asksQuestion, formsShown, joinRelay, noticeCutOff, pendingOf, pendingQuestions, personEvents, portsAt, registerPersonRpc, relayQuestions, } from "./person/index.js";
11
11
  import { addMemoryTools, lessonsOf, memoryEvents, NOTE_TOOL, readMemory, RECALL_TOOL, registerMemoryRpc, sweepRecords } from "./memory/index.js";
12
12
  import { addSkill } from "./skill.js";
13
- import { addTreeTools, deliverReleased, wakeMissed, dirtyOf, headOf, inspectWorktree, pruneExpired, readLimits, readNativeChildren, registerTreeRpc, shapeContext, together, treeEvents, waitsOn, } from "./tree/index.js";
13
+ import { addTreeTools, deliverReleased, wakeMissed, dirtyOf, headOf, inspectWorktree, onIdle, pruneExpired, readLimits, readNativeChildren, registerTreeRpc, shapeContext, together, treeEvents, waitsOn, } from "./tree/index.js";
14
14
  import { addWebhookTools, joinReceiver, readConfig, receiving, subscribedSince, } from "./webhook/index.js";
15
15
  /** The spawns under way in the process, outside the hub, so spawns in other locations, copies and hub versions count them. */
16
16
  const gate = sharedState("spawning", () => ({ reserved: new Set(), turn: Promise.resolve() }));
@@ -171,6 +171,11 @@ export const courier = () => Plugin.define({
171
171
  log: later.log,
172
172
  nudge,
173
173
  };
174
+ // A spawned session about to go idle owing a report is reminded once, in the same execution. The hook is
175
+ // experimental, and a host before 2.2.4 lacks it: the rest loads all the same, and its parents are told as before.
176
+ await Promise.resolve()
177
+ .then(() => ctx.session.hook("experimental.idle", (event) => onIdle(watchPorts, watchState.seen, outside, event).then(() => undefined)))
178
+ .catch((error) => log(`courier idle: not registered, a child that ends without a report is not reminded: ${String(error)}`));
174
179
  // The hub's subscriptions to OpenCode's events, one and a standby, start with this copy's modules.
175
180
  watchFromHub(hub, eventModules);
176
181
  const member = {
@@ -3,7 +3,8 @@ import { claim, SEEN_MAX, setBounded } from "../platform/bounded.js";
3
3
  import { endsIdle, retryDelay } from "./domain.js";
4
4
  import { forgetChild, forgetDeleted } from "./forget.js";
5
5
  import { hasCompleteGroup, leave, outIn } from "./group.js";
6
- import { awaited, reportOf, stoppedSincePrompt } from "./report.js";
6
+ import { owedUnwaited } from "./nudge.js";
7
+ import { stoppedSincePrompt } from "./report.js";
7
8
  import { syncStanding } from "./standing.js";
8
9
  import { childStore, groupStore } from "./store.js";
9
10
  import { lastReply } from "./status.js";
@@ -71,6 +72,9 @@ export async function notePrompt(ports, event, inbox) {
71
72
  const parentID = await children.indexedParent(sessionID);
72
73
  if (parentID === undefined)
73
74
  return false;
75
+ // The reminder to report is not a prompt: it asks for the report the last one owes.
76
+ if (inboxID !== undefined && (await children.nudgeOf(sessionID)).inboxID === inboxID)
77
+ return false;
74
78
  await children.prompted(sessionID, event.created ?? ports.now());
75
79
  await syncStanding(ports, parentID);
76
80
  return true;
@@ -122,20 +126,6 @@ export const waitsOn = (...suppliers) => async (ports, sessionID, since) => {
122
126
  };
123
127
  /** A `Waits` asking `suppliers` at once, as cheap as each other: it waits when one does, and fails when one fails. */
124
128
  export const together = (...suppliers) => async (ports, sessionID, since) => (await Promise.all(suppliers.map((supplier) => supplier(ports, sessionID, since)))).includes(true);
125
- /**
126
- * Whether a session prompted at `prompt` waits: on an untold report of a session it started, a complete group of
127
- * its about to be delivered, or what `outside` says it waits on. Cheapest first.
128
- */
129
- async function waits(ports, outside, sessionID, prompt) {
130
- const children = childStore(ports.storage);
131
- const started = await children.children(sessionID);
132
- if ((await Promise.all(started.map((entry) => awaited(children, entry.sessionID)))).includes(true))
133
- return true;
134
- // A group with a member still out is waited on through that member, above; one whose every out member was told of is not.
135
- if (started.some((entry) => entry.group) && (await hasCompleteGroup(groupStore(ports.storage), sessionID)))
136
- return true;
137
- return outside(ports, sessionID, prompt);
138
- }
139
129
  /**
140
130
  * Tells the parent of a spawned session whose turn ended without reporting to it since its last
141
131
  * prompt, unless it waits; finds the session by the reverse index alone. Returns the parents told.
@@ -147,10 +137,8 @@ export async function reportSilent(ports, seen, event, outside) {
147
137
  // Claimed after the lookup, so other sessions' turns do not crowd `seen`, and before anything is told.
148
138
  if (!entry || !claim(seen, event.id))
149
139
  return [];
150
- const report = await reportOf(children, sessionID);
151
- if (!report?.owes || (await waits(ports, outside, sessionID, report.prompt)))
152
- return [];
153
- if (await stoppedSincePrompt(children, sessionID).catch(() => false))
140
+ const report = await owedUnwaited(ports, children, outside, sessionID);
141
+ if (!report)
154
142
  return [];
155
143
  // The notice goes out without the reply rather than not at all.
156
144
  const lastText = await ports.session.context({ sessionID }).then(lastReply, () => undefined);
@@ -3,6 +3,7 @@ export { shapeContext } from "./context.js";
3
3
  export { endsIdle, lineageIn, nativeRootOf, readLimits, readNativeChildren } from "./domain.js";
4
4
  export { together, treeEvents, waitsOn } from "./events.js";
5
5
  export { deliverReleased } from "./group.js";
6
+ export { onIdle } from "./nudge.js";
6
7
  export { contextOf } from "./panel.js";
7
8
  export type { CleanupPorts, CourierPorts, Entries, Entry, Instructions, ModelWindow, Pending, PendingPrompt, Role, RosterEntry, RosterStorage, ShownForm, SpawnGate, Waits, WatchPorts, WatchState, } from "./ports.js";
8
9
  export { allEntries, answeringTop, childrenOf, indexedLineage, lineage, pruneExpired, reportState, RETENTION_MS, type ReportState } from "./roster.js";
@@ -5,6 +5,7 @@ export { shapeContext } from "./context.js";
5
5
  export { endsIdle, lineageIn, nativeRootOf, readLimits, readNativeChildren } from "./domain.js";
6
6
  export { together, treeEvents, waitsOn } from "./events.js";
7
7
  export { deliverReleased } from "./group.js";
8
+ export { onIdle } from "./nudge.js";
8
9
  export { contextOf } from "./panel.js";
9
10
  export { allEntries, answeringTop, childrenOf, indexedLineage, lineage, pruneExpired, reportState, RETENTION_MS } from "./roster.js";
10
11
  export { runs, turnIn, turnOf } from "./running.js";
@@ -0,0 +1,28 @@
1
+ import type { ChildStore, Waits, WatchPorts } from "./ports.js";
2
+ /** OpenCode's `experimental.idle` hook event: a session about to go idle, and the background work still running for it or below it. */
3
+ export interface Idle {
4
+ readonly sessionID: string;
5
+ readonly background: ReadonlyArray<{
6
+ readonly sessionID: string;
7
+ }>;
8
+ }
9
+ /** The ports the reminder is sent through: the watcher's. */
10
+ export type NudgePorts = Pick<WatchPorts, "storage" | "session" | "permissions" | "now" | "log">;
11
+ /**
12
+ * The report a spawned session owes since its last prompt, unless it waits or courier_stop stopped it since: what the
13
+ * silent-turn notice tells the parent of, and the reminder reminds the session of.
14
+ */
15
+ export declare function owedUnwaited(ports: NudgePorts, children: ChildStore, outside: Waits, sessionID: string): Promise<{
16
+ resumesAt?: number | undefined;
17
+ prompt: number;
18
+ owes: boolean;
19
+ progressed: boolean;
20
+ ended: boolean;
21
+ } | undefined>;
22
+ /**
23
+ * Reminds a spawned session about to go idle owing a report it does not wait on, once per prompt, so it reports in the
24
+ * same execution; one with background work of its own is woken again by it. Returns whether it was reminded.
25
+ */
26
+ export declare function nudgeSilent(ports: NudgePorts, seen: Set<string>, outside: Waits, idle: Idle): Promise<boolean>;
27
+ /** `nudgeSilent` as the hook runs it: a hook that fails fails the execution, so a failure is logged, never thrown. */
28
+ export declare function onIdle(ports: NudgePorts, seen: Set<string>, outside: Waits, idle: Idle): Promise<boolean>;
@@ -0,0 +1,75 @@
1
+ import { envelope } from "../kernel/protocol.js";
2
+ import { claim } from "../platform/bounded.js";
3
+ import { hasCompleteGroup } from "./group.js";
4
+ import { awaited, reportOf, stoppedSincePrompt } from "./report.js";
5
+ import { childStore, groupStore } from "./store.js";
6
+ import { reportReminder } from "./text.js";
7
+ /**
8
+ * Whether a session prompted at `prompt` waits: on an untold report of a session it started, a complete group of
9
+ * its about to be delivered, or what `outside` says it waits on. Cheapest first.
10
+ */
11
+ async function waits(ports, outside, sessionID, prompt) {
12
+ const children = childStore(ports.storage);
13
+ const started = await children.children(sessionID);
14
+ if ((await Promise.all(started.map((entry) => awaited(children, entry.sessionID)))).includes(true))
15
+ return true;
16
+ // A group with a member still out is waited on through that member, above; one whose every out member was told of is not.
17
+ if (started.some((entry) => entry.group) && (await hasCompleteGroup(groupStore(ports.storage), sessionID)))
18
+ return true;
19
+ return outside(ports, sessionID, prompt);
20
+ }
21
+ /**
22
+ * The report a spawned session owes since its last prompt, unless it waits or courier_stop stopped it since: what the
23
+ * silent-turn notice tells the parent of, and the reminder reminds the session of.
24
+ */
25
+ export async function owedUnwaited(ports, children, outside, sessionID) {
26
+ const report = await reportOf(children, sessionID);
27
+ if (!report?.owes || (await waits(ports, outside, sessionID, report.prompt)))
28
+ return undefined;
29
+ if (await stoppedSincePrompt(children, sessionID).catch(() => false))
30
+ return undefined;
31
+ return report;
32
+ }
33
+ /**
34
+ * Reminds a spawned session about to go idle owing a report it does not wait on, once per prompt, so it reports in the
35
+ * same execution; one with background work of its own is woken again by it. Returns whether it was reminded.
36
+ */
37
+ export async function nudgeSilent(ports, seen, outside, idle) {
38
+ const { sessionID } = idle;
39
+ if (idle.background.some((work) => work.sessionID === sessionID))
40
+ return false;
41
+ const children = childStore(ports.storage);
42
+ const parentID = await children.indexedParent(sessionID);
43
+ if (parentID === undefined)
44
+ return false;
45
+ const report = await owedUnwaited(ports, children, outside, sessionID);
46
+ if (!report)
47
+ return false;
48
+ const { at: nudgedAt } = await children.nudgeOf(sessionID);
49
+ if ((nudgedAt !== undefined && nudgedAt >= report.prompt) || !claim(seen, `nudge/${sessionID}/${report.prompt}`))
50
+ return false;
51
+ const at = Math.max(ports.now(), report.prompt);
52
+ // Noted before it is sent, so a second hook never sends another, and with its id after, before the turn takes it.
53
+ await children.nudged(sessionID, at);
54
+ const { id } = await ports.session.synthetic({
55
+ sessionID,
56
+ text: envelope("courier", reportReminder(parentID), { reminder: "report" }),
57
+ description: "Reminded to report",
58
+ metadata: { source: "courier", reminder: "report", resume: false },
59
+ delivery: "steer",
60
+ // The hook's own execution takes it: no wake, and the missed-wake watch leaves it be.
61
+ resume: false,
62
+ });
63
+ await children.nudged(sessionID, at, id);
64
+ return true;
65
+ }
66
+ /** `nudgeSilent` as the hook runs it: a hook that fails fails the execution, so a failure is logged, never thrown. */
67
+ export async function onIdle(ports, seen, outside, idle) {
68
+ try {
69
+ return await nudgeSilent(ports, seen, outside, idle);
70
+ }
71
+ catch (error) {
72
+ ports.log(`courier idle: could not remind ${idle.sessionID} to report: ${String(error)}`);
73
+ return false;
74
+ }
75
+ }
@@ -12,7 +12,7 @@ export type RosterStorage = Storage;
12
12
  /** How a session's wait for a report ended: it reported to its parent, or its turn failed or was interrupted. */
13
13
  export type Settled = "report" | "failed" | "interrupted";
14
14
  /** The notes kept of a spawned session's report state; docs/maintainers/architecture.md has each. */
15
- export type ReportField = "prompt" | "settled" | "progress" | "told" | "stopped";
15
+ export type ReportField = "prompt" | "settled" | "progress" | "told" | "stopped" | "nudged";
16
16
  /** A report note as read: when it was made, and for `settled` how the wait ended, the report's status or a failure's `resumesAt`, as stored. */
17
17
  export interface ReportNote {
18
18
  readonly at: number | undefined;
@@ -75,6 +75,13 @@ export interface ChildStore {
75
75
  untold(sessionID: string, at: number): Promise<void>;
76
76
  /** Notes that courier_stop stopped a spawned session at `at`. */
77
77
  stopped(sessionID: string, at: number): Promise<void>;
78
+ /** Notes that a spawned session was reminded at `at` to report, with the reminder's inbox id once it is admitted. */
79
+ nudged(sessionID: string, at: number, inboxID?: string): Promise<void>;
80
+ /** When a spawned session was last reminded to report, and the reminder's inbox id. */
81
+ nudgeOf(sessionID: string): Promise<{
82
+ readonly at: number | undefined;
83
+ readonly inboxID?: string;
84
+ }>;
78
85
  /** A `settled` or `progress` note as stored, for `restore` to put back. */
79
86
  peek(sessionID: string, field: "settled" | "progress"): Promise<Stored | undefined>;
80
87
  /** Puts back a note `peek` read; one that was not there is removed. */
@@ -14,6 +14,7 @@ export declare const settledKey: (sessionID: string) => string;
14
14
  export declare const progressKey: (sessionID: string) => string;
15
15
  export declare const toldKey: (sessionID: string) => string;
16
16
  export declare const stoppedKey: (sessionID: string) => string;
17
+ export declare const nudgedKey: (sessionID: string) => string;
17
18
  export declare const memberKey: (parentID: string, group: string, sessionID: string) => string;
18
19
  /** The key prefix of a parent's groups, and of one group's members. */
19
20
  export declare const groupsPrefix: (parentID: string) => string;
@@ -17,8 +17,8 @@ export function reverseKey(sessionID) {
17
17
  return `${BY_CHILD}${sessionID}`;
18
18
  }
19
19
  /**
20
- * Whether a spawned session has reported since its last prompt, under `report/<sessionID>/`: `prompt`, `settled` (with
21
- * the report's status, or when a failed turn's wait lifts), `progress` (a message to its parent without a status) and `told`; docs/maintainers/architecture.md has each.
20
+ * Whether a spawned session has reported since its last prompt, under `report/<sessionID>/`: `prompt`, `settled`, `progress`,
21
+ * `told`, `stopped` and `nudged` (the reminder to report); docs/maintainers/architecture.md has each.
22
22
  */
23
23
  const REPORT_PREFIX = "report/";
24
24
  export const promptKey = (sessionID) => `${REPORT_PREFIX}${sessionID}/prompt`;
@@ -26,12 +26,14 @@ export const settledKey = (sessionID) => `${REPORT_PREFIX}${sessionID}/settled`;
26
26
  export const progressKey = (sessionID) => `${REPORT_PREFIX}${sessionID}/progress`;
27
27
  export const toldKey = (sessionID) => `${REPORT_PREFIX}${sessionID}/told`;
28
28
  export const stoppedKey = (sessionID) => `${REPORT_PREFIX}${sessionID}/stopped`;
29
+ export const nudgedKey = (sessionID) => `${REPORT_PREFIX}${sessionID}/nudged`;
29
30
  const REPORT_KEYS = {
30
31
  prompt: promptKey,
31
32
  settled: settledKey,
32
33
  progress: progressKey,
33
34
  told: toldKey,
34
35
  stopped: stoppedKey,
36
+ nudged: nudgedKey,
35
37
  };
36
38
  /**
37
39
  * Join groups: each member of a parent's group under `group/<parentID>/<name>/<sessionID>`, with its
@@ -195,6 +197,14 @@ export function childStore(storage) {
195
197
  async stopped(sessionID, at) {
196
198
  await storage.set(stoppedKey(sessionID), { at });
197
199
  },
200
+ async nudged(sessionID, at, inboxID) {
201
+ await storage.set(nudgedKey(sessionID), { at, ...(inboxID === undefined ? {} : { inboxID }) });
202
+ },
203
+ async nudgeOf(sessionID) {
204
+ const value = await storage.get(nudgedKey(sessionID));
205
+ const inboxID = str(obj(value).inboxID);
206
+ return { at: timeOf(value), ...(inboxID === undefined ? {} : { inboxID }) };
207
+ },
198
208
  async peek(sessionID, field) {
199
209
  const value = await storage.get(REPORT_KEYS[field](sessionID));
200
210
  return value;
@@ -108,6 +108,8 @@ export declare function keepReason(state: {
108
108
  * retry time (`retryAfterMs`) counts from. Message the child before that and it fails again, so the notice says to wait.
109
109
  */
110
110
  export declare function failureNotice(title: string, error: ExecutionError, group?: string, at?: number): string;
111
+ /** The one reminder a spawned session gets as it is about to end its turn owing its parent a report; it carries on in the same turn. */
112
+ export declare function reportReminder(parentID: string): string;
111
113
  /**
112
114
  * What the parent is told when a session it started ends its turn without a report to it;
113
115
  * `progressed` when it messaged the parent without a status since its last prompt.
package/dist/tree/text.js CHANGED
@@ -364,6 +364,15 @@ export function failureNotice(title, error, group, at) {
364
364
  : []),
365
365
  ].join("\n");
366
366
  }
367
+ /** The one reminder a spawned session gets as it is about to end its turn owing its parent a report; it carries on in the same turn. */
368
+ export function reportReminder(parentID) {
369
+ return [
370
+ `You are about to end your turn without reporting to session ${parentID}, which started you.`,
371
+ `If you are finished, send your report now: call courier_send with sessionID "${parentID}", a short account of what you did and ` +
372
+ `status ${STATUS_LIST}. If work is left, carry on and report when you finish.`,
373
+ "This reminder comes once: if you end your turn without a report, the session that started you is told.",
374
+ ].join("\n");
375
+ }
367
376
  /** Longest last reply a notice that a session ended its turn without reporting quotes. */
368
377
  const MAX_REPLY = 2000;
369
378
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-courier",
3
- "version": "0.13.6",
3
+ "version": "0.13.7",
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>",
@@ -56,12 +56,12 @@
56
56
  "test:e2e": "e2e/run.sh"
57
57
  },
58
58
  "peerDependencies": {
59
- "@opencode/plugin": "2.2.4",
59
+ "@opencode/plugin": "2.2.5",
60
60
  "effect": "4.0.6"
61
61
  },
62
62
  "devDependencies": {
63
- "@opencode/plugin": "2.2.4",
64
- "@opencode/theme": "2.2.4",
63
+ "@opencode/plugin": "2.2.5",
64
+ "@opencode/theme": "2.2.5",
65
65
  "@opentui/core": "0.8.0",
66
66
  "@opentui/solid": "0.8.0",
67
67
  "@types/bun": "1.4.2",