opencode-courier 0.13.8 → 0.15.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 (163) hide show
  1. package/README.md +7 -9
  2. package/dist/index.d.ts +0 -8
  3. package/dist/index.js +7 -54
  4. package/dist/kernel/protocol.d.ts +3 -17
  5. package/dist/kernel/protocol.js +3 -17
  6. package/dist/kernel/tool.d.ts +0 -2
  7. package/dist/kernel/tool.js +0 -1
  8. package/dist/memory/domain.d.ts +6 -85
  9. package/dist/memory/domain.js +7 -63
  10. package/dist/memory/events.d.ts +0 -11
  11. package/dist/memory/events.js +1 -12
  12. package/dist/memory/forget.d.ts +0 -5
  13. package/dist/memory/forget.js +0 -4
  14. package/dist/memory/index.js +0 -2
  15. package/dist/memory/lessons.d.ts +1 -9
  16. package/dist/memory/lessons.js +2 -11
  17. package/dist/memory/note.d.ts +2 -11
  18. package/dist/memory/note.js +5 -11
  19. package/dist/memory/pin.d.ts +0 -5
  20. package/dist/memory/pin.js +0 -4
  21. package/dist/memory/ports.d.ts +2 -22
  22. package/dist/memory/recall.d.ts +0 -5
  23. package/dist/memory/recall.js +0 -12
  24. package/dist/memory/record.d.ts +0 -4
  25. package/dist/memory/record.js +8 -31
  26. package/dist/memory/rpc-handlers.d.ts +3 -6
  27. package/dist/memory/rpc-handlers.js +0 -6
  28. package/dist/memory/rpc.d.ts +6 -19
  29. package/dist/memory/rpc.js +1 -16
  30. package/dist/memory/store.d.ts +0 -5
  31. package/dist/memory/store.js +1 -16
  32. package/dist/memory/sweep.d.ts +0 -4
  33. package/dist/memory/sweep.js +0 -4
  34. package/dist/memory/text.d.ts +1 -26
  35. package/dist/memory/text.js +8 -38
  36. package/dist/memory/tools.d.ts +0 -2
  37. package/dist/memory/tools.js +4 -6
  38. package/dist/memory/tui.d.ts +1 -35
  39. package/dist/person/events.d.ts +0 -16
  40. package/dist/person/events.js +3 -30
  41. package/dist/person/form.d.ts +0 -9
  42. package/dist/person/form.js +0 -10
  43. package/dist/person/index.d.ts +0 -2
  44. package/dist/person/permission.d.ts +0 -34
  45. package/dist/person/permission.js +2 -28
  46. package/dist/person/ports.d.ts +0 -16
  47. package/dist/person/ports.js +0 -1
  48. package/dist/person/question/answer.d.ts +0 -28
  49. package/dist/person/question/answer.js +0 -49
  50. package/dist/person/question/index.js +0 -2
  51. package/dist/person/question/lifecycle.d.ts +0 -26
  52. package/dist/person/question/lifecycle.js +1 -32
  53. package/dist/person/question/pure.d.ts +0 -11
  54. package/dist/person/question/pure.js +0 -11
  55. package/dist/person/question/relay.d.ts +0 -1
  56. package/dist/person/question/relay.js +5 -67
  57. package/dist/person/question/runtime.d.ts +0 -14
  58. package/dist/person/question/runtime.js +1 -26
  59. package/dist/person/question/shared.d.ts +0 -17
  60. package/dist/person/question/shared.js +0 -20
  61. package/dist/person/question/types.d.ts +0 -44
  62. package/dist/person/rpc-handlers.d.ts +0 -7
  63. package/dist/person/rpc-handlers.js +0 -6
  64. package/dist/person/rpc.d.ts +0 -20
  65. package/dist/person/rpc.js +0 -16
  66. package/dist/person/text.d.ts +0 -45
  67. package/dist/person/text.js +2 -54
  68. package/dist/person/tools.d.ts +0 -3
  69. package/dist/person/tools.js +3 -4
  70. package/dist/person/tui.d.ts +0 -21
  71. package/dist/platform/bounded.d.ts +0 -8
  72. package/dist/platform/bounded.js +0 -8
  73. package/dist/platform/events.d.ts +0 -18
  74. package/dist/platform/events.js +3 -22
  75. package/dist/platform/hub.d.ts +0 -113
  76. package/dist/platform/hub.js +1 -76
  77. package/dist/platform/json.d.ts +0 -3
  78. package/dist/platform/json.js +1 -3
  79. package/dist/platform/storage.d.ts +0 -7
  80. package/dist/platform/storage.js +0 -6
  81. package/dist/platform/version.d.ts +0 -9
  82. package/dist/platform/version.js +0 -8
  83. package/dist/rpc.js +0 -1
  84. package/dist/scheduling/index.d.ts +0 -1
  85. package/dist/scheduling/index.js +0 -1
  86. package/dist/scheduling/later.d.ts +0 -11
  87. package/dist/scheduling/later.js +0 -14
  88. package/dist/scheduling/text.d.ts +0 -1
  89. package/dist/scheduling/text.js +0 -1
  90. package/dist/scheduling/tools.d.ts +0 -1
  91. package/dist/scheduling/tools.js +6 -8
  92. package/dist/skill.d.ts +1 -9
  93. package/dist/skill.js +40 -97
  94. package/dist/tree/cleanup.d.ts +0 -20
  95. package/dist/tree/cleanup.js +0 -26
  96. package/dist/tree/context.d.ts +0 -5
  97. package/dist/tree/context.js +17 -13
  98. package/dist/tree/domain.d.ts +0 -64
  99. package/dist/tree/domain.js +7 -45
  100. package/dist/tree/events.d.ts +5 -36
  101. package/dist/tree/events.js +23 -55
  102. package/dist/tree/forget.d.ts +0 -15
  103. package/dist/tree/forget.js +0 -18
  104. package/dist/tree/group.d.ts +0 -22
  105. package/dist/tree/group.js +6 -39
  106. package/dist/tree/index.d.ts +2 -2
  107. package/dist/tree/index.js +1 -3
  108. package/dist/tree/limits.d.ts +1 -4
  109. package/dist/tree/limits.js +9 -13
  110. package/dist/tree/mascot.d.ts +0 -25
  111. package/dist/tree/mascot.js +1 -32
  112. package/dist/tree/model.d.ts +0 -5
  113. package/dist/tree/model.js +2 -15
  114. package/dist/tree/nudge.d.ts +0 -11
  115. package/dist/tree/nudge.js +3 -16
  116. package/dist/tree/panel.d.ts +0 -8
  117. package/dist/tree/panel.js +3 -16
  118. package/dist/tree/ports.d.ts +5 -139
  119. package/dist/tree/report.d.ts +1 -10
  120. package/dist/tree/report.js +4 -11
  121. package/dist/tree/roster.d.ts +0 -21
  122. package/dist/tree/roster.js +0 -21
  123. package/dist/tree/rpc-handlers.d.ts +6 -1
  124. package/dist/tree/rpc-handlers.js +0 -5
  125. package/dist/tree/rpc.d.ts +9 -109
  126. package/dist/tree/rpc.js +2 -38
  127. package/dist/tree/running.d.ts +0 -6
  128. package/dist/tree/running.js +2 -10
  129. package/dist/tree/send.d.ts +0 -4
  130. package/dist/tree/send.js +4 -31
  131. package/dist/tree/spawn.d.ts +1 -7
  132. package/dist/tree/spawn.js +15 -56
  133. package/dist/tree/standing.d.ts +0 -8
  134. package/dist/tree/standing.js +1 -11
  135. package/dist/tree/status.d.ts +0 -8
  136. package/dist/tree/status.js +1 -9
  137. package/dist/tree/stop.d.ts +2 -9
  138. package/dist/tree/stop.js +58 -25
  139. package/dist/tree/store.d.ts +0 -8
  140. package/dist/tree/store.js +4 -38
  141. package/dist/tree/subtree.d.ts +8 -26
  142. package/dist/tree/subtree.js +37 -35
  143. package/dist/tree/summary.d.ts +0 -4
  144. package/dist/tree/summary.js +0 -8
  145. package/dist/tree/text.d.ts +4 -46
  146. package/dist/tree/text.js +44 -83
  147. package/dist/tree/tools.d.ts +0 -6
  148. package/dist/tree/tools.js +15 -15
  149. package/dist/tree/tui.d.ts +0 -81
  150. package/dist/tree/wake.d.ts +0 -30
  151. package/dist/tree/wake.js +5 -39
  152. package/dist/tui.js +24 -10
  153. package/package.json +8 -9
  154. package/dist/webhook/index.d.ts +0 -5
  155. package/dist/webhook/index.js +0 -3
  156. package/dist/webhook/receiver.d.ts +0 -80
  157. package/dist/webhook/receiver.js +0 -339
  158. package/dist/webhook/subscriptions.d.ts +0 -29
  159. package/dist/webhook/subscriptions.js +0 -52
  160. package/dist/webhook/text.d.ts +0 -23
  161. package/dist/webhook/text.js +0 -82
  162. package/dist/webhook/tools.d.ts +0 -19
  163. package/dist/webhook/tools.js +0 -46
@@ -1,6 +1,5 @@
1
1
  import { millis, obj } from "../platform/json.js";
2
2
  export const DEFAULT_LIMITS = { maxDepth: 5, maxChildren: 5, maxTotal: 30 };
3
- /** The limits in the plugin's options; one that is not a positive integer keeps its default and is named in `problems`. */
4
3
  export function readLimits(options) {
5
4
  const given = obj(options);
6
5
  const problems = [];
@@ -16,43 +15,27 @@ export function readLimits(options) {
16
15
  const limits = { maxDepth: read("maxDepth"), maxChildren: read("maxChildren"), maxTotal: read("maxTotal") };
17
16
  return { limits, problems };
18
17
  }
19
- /** The `nativeChildren` option: whether a child started without isolate or `native` gets OpenCode's own `parentID`; off unless `true`. */
20
18
  export function readNativeChildren(options) {
21
19
  const value = obj(options).nativeChildren;
22
20
  if (value === undefined || typeof value === "boolean")
23
21
  return { nativeChildren: value === true, problems: [] };
24
22
  return { nativeChildren: false, problems: [`nativeChildren must be true or false, not ${JSON.stringify(value)}; using false`] };
25
23
  }
26
- /**
27
- * Whether a session may run by its times: it has never finished a turn, or its latest execution started after the last one ended
28
- * (`busy`; without it `updated`, which a rename, an archive or a waiting inbox item also stamp); only its transcript then tells.
29
- */
24
+ // time.busy exists since 2.2.2; time.updated is no sign of activity (api-notes)
30
25
  export const busy = (time) => time.idle === undefined || millis(time.busy ?? time.updated) > millis(time.idle);
31
- /** The messages the person's switch of agent or model, move or shell command appends, at rest or mid-turn alike. */
26
+ // a person's switches and shell commands append these, at rest or mid-turn
32
27
  const ASIDES = new Set(["agent-switched", "model-switched", "location-switched", "shell"]);
33
- /**
34
- * The message a transcript's last turn ended on, unless one runs, past asides made since: an `idle` message, or a step
35
- * interrupted with none after it, as a declined permission or dismissed question ends a turn, recording no `idle` (#326).
36
- */
28
+ // a decline or dismissed question ends on an aborted step, no idle (api-notes)
37
29
  export function turnEnd(messages) {
38
30
  const last = messages.findLast((message) => !ASIDES.has(message.type));
39
31
  return last?.type === "idle" || (last?.type === "assistant" && last.error?.type === "aborted") ? last : undefined;
40
32
  }
41
33
  export const endsIdle = (messages) => turnEnd(messages) !== undefined;
42
- /** When the turn `end` ended: the session's idle time for an `idle` message, else when its interrupted step ended. */
43
34
  export function endedAt(end, time) {
44
35
  const at = end.type === "idle" ? millis(time.idle) : millis(end.time?.completed ?? end.time?.created);
45
36
  return Number.isFinite(at) ? at : undefined;
46
37
  }
47
- /**
48
- * Whether a session of a tree read is live, for a cut listing's order: it runs (its turn not failed or interrupted since its last
49
- * prompt), waits on a request or form, or owes a report, but not, unlike for the limits, for a message waiting in its inbox.
50
- */
51
38
  export const isLive = (running, waits, report) => running !== undefined && ((running && !report?.ended) || waits > 0 || (report?.owes ?? false));
52
- /**
53
- * Which sessions a listing of at most `max` keeps, of a tree read in the order it is drawn: the live ones first, then
54
- * the newest, each with the sessions above it so the tree still draws; `leftOut` counts the rest.
55
- */
56
39
  export function cut(nodes, max) {
57
40
  const byID = new Map(nodes.map((node) => [node.sessionID, node]));
58
41
  const ranked = nodes
@@ -70,51 +53,34 @@ export function cut(nodes, max) {
70
53
  }
71
54
  return { kept: nodes.filter((node) => kept.has(node.sessionID)), leftOut: nodes.length - kept.size };
72
55
  }
73
- /**
74
- * Entries older than this are dropped when their parent's roster is read, and on plugin setup,
75
- * except isolated children whose worktree is still there: those stay until courier_cleanup.
76
- */
77
56
  export const RETENTION_MS = 14 * 24 * 60 * 60_000;
78
- /**
79
- * Whether an entry is past retention at `now`: recorded more than `RETENTION_MS` before, unless it is an isolated
80
- * child whose worktree exists, which is kept so it can still be cleaned up.
81
- */
82
57
  export const isExpired = (entry, now, exists) => now - entry.createdAt > RETENTION_MS && !(entry.isolated && exists(entry.directory));
83
- /** How a child was started, as courier_children and the panel show it: isolated, native when it is, and its model when not its parent's. */
84
58
  export function startedAs(entry) {
85
59
  return { isolated: entry.isolated, ...(entry.native ? { native: true } : {}), ...(entry.model ? { model: entry.model } : {}) };
86
60
  }
87
- /** `provider/model` as `courier_spawn` takes it: one slash, nothing empty on either side. */
88
61
  export function parseModelName(text) {
89
62
  const slash = text.indexOf("/");
90
63
  if (slash <= 0 || slash === text.length - 1)
91
64
  return undefined;
92
65
  return { providerID: text.slice(0, slash), id: text.slice(slash + 1) };
93
66
  }
94
- /** A model as the roster, the panel and the results name it: `provider/model`, with `#variant` for a variant that is not the default. */
95
67
  export function modelName(model) {
96
68
  const variant = model.variant && model.variant !== "default" ? `#${model.variant}` : "";
97
69
  return `${model.providerID}/${model.id}${variant}`;
98
70
  }
99
- /** `lineage` over roster entries already read. */
100
71
  export function lineageIn(entries, sessionID) {
101
72
  return lineageBy(bySession(entries), sessionID);
102
73
  }
103
- /** Roster entries by their session, for `lineageBy`. */
104
74
  export function bySession(entries) {
105
75
  return new Map(entries.map((entry) => [entry.sessionID, entry]));
106
76
  }
107
- /** `lineage` over roster entries already read and mapped by `bySession`. */
108
77
  export function lineageBy(bySession, sessionID) {
109
78
  const chain = [];
110
79
  for (let entry = bySession.get(sessionID); entry && !chain.includes(entry); entry = bySession.get(entry.parentID))
111
80
  chain.push(entry);
112
81
  return chain;
113
82
  }
114
- /**
115
- * The session whose view OpenCode shows the asks of a lineage's first session in: up its native parents, to the
116
- * first session that is not a native child (an isolated one, or the top). The session itself when it is not native.
117
- */
83
+ // OpenCode shows a native child's asks in its root's view (api-notes)
118
84
  export function nativeRootOf(chain) {
119
85
  let root = chain[0].sessionID;
120
86
  for (const entry of chain) {
@@ -124,10 +90,8 @@ export function nativeRootOf(chain) {
124
90
  }
125
91
  return root;
126
92
  }
127
- /** What a group may be called: short, and with no `/`, which separates the parts of its keys, or `"`, which closes an attribute. */
128
- const NAME = /^[\w.-]{1,60}$/;
129
- export const isGroupName = (value) => typeof value === "string" && NAME.test(value);
130
- /** A member's standing from its stored value, or `released` when there is none. */
93
+ const GROUP_NAME = /^[\w.-]{1,60}$/;
94
+ export const isGroupName = (value) => typeof value === "string" && GROUP_NAME.test(value);
131
95
  export function standingOf(member) {
132
96
  if (!member)
133
97
  return "released";
@@ -135,11 +99,9 @@ export function standingOf(member) {
135
99
  return "held";
136
100
  return member.left?.by ?? "out";
137
101
  }
138
- /** Whether every member of a group has a held report or has left: released once the parent's turn has ended. */
139
102
  export const complete = (members) => members.every((member) => member.report || member.left);
140
- /** Longer than this is no wait a notice can plan around; OpenCode clamps a header to 2^53 ms, past what a `Date` holds. */
103
+ // OpenCode clamps retry-after to 2^53 ms, past what a Date holds
141
104
  const RETRY_MAX_MS = 30 * 24 * 3_600_000;
142
- /** How long the provider asked to wait after a failed turn, if it did: a positive number of milliseconds, at most `RETRY_MAX_MS`. */
143
105
  export function retryDelay(error) {
144
106
  const wait = error.retryAfterMs;
145
107
  return typeof wait === "number" && Number.isFinite(wait) && wait > 0 && wait <= RETRY_MAX_MS ? wait : undefined;
@@ -1,16 +1,13 @@
1
1
  import type { EventModule } from "../platform/events.js";
2
2
  import type { ExecutionError, Waits, WatchPorts, WatchState } from "./ports.js";
3
- /** The part of OpenCode's `session.execution.failed` event the notice is made from. */
4
3
  export interface ExecutionFailed {
5
4
  readonly id: string;
6
- /** When OpenCode published it, in epoch milliseconds. */
7
5
  readonly created?: number;
8
6
  readonly data: {
9
7
  readonly sessionID: string;
10
8
  readonly error: ExecutionError;
11
9
  };
12
10
  }
13
- /** OpenCode's events that concern one session's turns and inbox: `session.execution.succeeded` and the like. */
14
11
  export interface SessionEvent {
15
12
  readonly id: string;
16
13
  readonly created?: number;
@@ -19,58 +16,30 @@ export interface SessionEvent {
19
16
  readonly reason?: string;
20
17
  };
21
18
  }
22
- /**
23
- * Tells the parent of a child whose turn failed, since a child that cannot reach its model cannot
24
- * call courier_send. Returns the parents told.
25
- */
26
19
  export declare function reportFailure(ports: WatchPorts, seen: Set<string>, event: ExecutionFailed): Promise<string[]>;
27
- /** OpenCode's `session.inbox.enqueued`: the item's type, which its `session.inbox.delivered` does not carry. */
28
20
  export interface InboxEnqueued {
29
21
  readonly data: {
30
22
  readonly sessionID: string;
31
23
  readonly inboxID: string;
32
24
  readonly item: {
33
25
  readonly type: string;
26
+ readonly payload?: {
27
+ readonly metadata?: {
28
+ readonly [key: string]: unknown;
29
+ };
30
+ };
34
31
  };
35
32
  };
36
33
  }
37
- /** Remembers the type of an item enqueued for a spawned session, for `notePrompt`; other sessions cost one read. */
38
34
  export declare function noteEnqueued(ports: WatchPorts, inbox: Map<string, string>, event: InboxEnqueued): Promise<boolean>;
39
- /**
40
- * Notes that a message reached a spawned session, which then owes its parent a report, unless `inbox` knows it for one of
41
- * `UNOWED` (`spawn` notes the task itself); reads the reverse index alone, and claims nothing: the note is the same twice.
42
- */
43
35
  export declare function notePrompt(ports: WatchPorts, event: SessionEvent & {
44
36
  readonly data: {
45
37
  readonly inboxID?: string;
46
38
  };
47
39
  }, inbox?: Map<string, string>): Promise<boolean>;
48
- /**
49
- * Notes that a spawned session's turn was stopped, which takes it out of its group without a report; a
50
- * shutdown's is left to `reportDeclined`, as one stopped between steps resumes on the next start.
51
- */
52
40
  export declare function noteInterrupted(ports: WatchPorts, event: SessionEvent): Promise<boolean>;
53
- /**
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
- */
57
41
  export declare function noteDeleted(ports: WatchPorts, event: SessionEvent): Promise<string[]>;
58
- /** A `Waits` asking `suppliers` one after another, cheapest first: the first that waits answers. */
59
42
  export declare const waitsOn: (...suppliers: Waits[]) => Waits;
60
- /** A `Waits` asking `suppliers` at once, as cheap as each other: it waits when one does, and fails when one fails. */
61
- export declare const together: (...suppliers: Waits[]) => Waits;
62
- /**
63
- * Tells the parent of a spawned session whose turn ended without reporting to it since its last
64
- * prompt, unless it waits; finds the session by the reverse index alone. Returns the parents told.
65
- */
66
43
  export declare function reportSilent(ports: WatchPorts, seen: Set<string>, event: SessionEvent, outside: Waits): Promise<string[]>;
67
- /**
68
- * A session's turn that OpenCode reports as a shutdown ended in a plain decline, if its transcript ends on an aborted step: its
69
- * parent is told as for a turn that succeeded without a report. A real shutdown during a step reads the same, and is told too.
70
- */
71
44
  export declare function reportDeclined(ports: WatchPorts, seen: Set<string>, event: SessionEvent, outside: Waits): Promise<string[]>;
72
- /**
73
- * Tells parents of a child's failed or silent turn, unless it waits on what `outside` says, and notes
74
- * its prompts, interruptions and deletion; `after` a turn's end, seals the groups of its session.
75
- */
76
45
  export declare function treeEvents(state: WatchState, outside: Waits): EventModule<WatchPorts>;
@@ -3,17 +3,14 @@ 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 { liveBelow } from "./limits.js";
6
7
  import { owedUnwaited } from "./nudge.js";
7
- import { stoppedSincePrompt } from "./report.js";
8
+ import { reportedSincePrompt, stoppedSincePrompt } from "./report.js";
8
9
  import { syncStanding } from "./standing.js";
9
10
  import { childStore, groupStore } from "./store.js";
10
11
  import { lastReply } from "./status.js";
11
12
  import { failureNotice, silentNotice } from "./text.js";
12
13
  import { forgetWakes, settleWake, wakeEnded, wakeStarted, watchWake } from "./wake.js";
13
- /**
14
- * Tells the parent of a child whose turn failed, since a child that cannot reach its model cannot
15
- * call courier_send. Returns the parents told.
16
- */
17
14
  export async function reportFailure(ports, seen, event) {
18
15
  if (!claim(seen, event.id))
19
16
  return [];
@@ -22,21 +19,21 @@ export async function reportFailure(ports, seen, event) {
22
19
  const entries = await children.entriesOf(sessionID);
23
20
  const at = event.created ?? ports.now();
24
21
  const wait = retryDelay(error);
25
- // Not in the way of the notice: kept only so the limits stop counting the child, and the panel can say when its limit lifts.
26
22
  if (entries.length)
27
23
  await children.settled(sessionID, "failed", at, undefined, wait === undefined ? undefined : at + wait).catch((error) => ports.log(`courier watch: could not note the failed turn of ${sessionID}: ${String(error)}`));
28
24
  const out = await Promise.all(entries.map((entry) => outIn(ports, entry)));
29
- // Left before the notice, so the parent waits on the group meanwhile; nudged after it, so the group's
30
- // message does not overtake the notice, short of an interval tick falling in between.
31
25
  const completed = await Promise.all(out.map((member) => (member ? leave(ports, member, "failed", at) : Promise.resolve(false))));
32
- // A session courier_stop stopped is not a failure to tell: its parent was told of the stop, or ordered it.
33
26
  const quiet = entries.length > 0 && (await stoppedSincePrompt(children, sessionID).catch(() => false));
34
27
  try {
35
28
  if (quiet)
36
29
  return [];
30
+ const below = await liveBelow(ports, sessionID).then((live) => live.map((entry) => entry.title), (error) => {
31
+ ports.log(`courier watch: could not read the sessions under ${sessionID}: ${String(error)}`);
32
+ return [];
33
+ });
37
34
  await Promise.all(entries.map((entry, index) => ports.session.synthetic({
38
35
  sessionID: entry.parentID,
39
- text: envelope(sessionID, failureNotice(entry.title, error, out[index]?.group, at), { failed: error.type }),
36
+ text: envelope(sessionID, failureNotice(entry.title, error, out[index]?.group, at, below), { failed: error.type }),
40
37
  description: `Session ${sessionID} failed`,
41
38
  metadata: { source: "courier", from: sessionID, failed: true },
42
39
  delivery: "steer",
@@ -49,40 +46,38 @@ export async function reportFailure(ports, seen, event) {
49
46
  }
50
47
  return entries.map((entry) => entry.parentID);
51
48
  }
52
- /** Inbox items that are not courier's or the parent's business: the person's own prompts, compactions and moves. */
49
+ // only inbox.enqueued carries the item's type (api-notes)
53
50
  const UNOWED = new Set(["user", "compaction", "move"]);
54
- /** Remembers the type of an item enqueued for a spawned session, for `notePrompt`; other sessions cost one read. */
51
+ const GROUP = "group";
52
+ function kindOf(item) {
53
+ const metadata = item.payload?.metadata;
54
+ return item.type === "synthetic" && metadata?.source === "courier" && typeof metadata.group === "string" ? GROUP : item.type;
55
+ }
55
56
  export async function noteEnqueued(ports, inbox, event) {
56
57
  const { sessionID, inboxID, item } = event.data;
57
58
  if ((await childStore(ports.storage).indexedParent(sessionID)) === undefined)
58
59
  return false;
59
- setBounded(inbox, inboxID, item.type, SEEN_MAX);
60
+ setBounded(inbox, inboxID, kindOf(item), SEEN_MAX);
60
61
  return true;
61
62
  }
62
- /**
63
- * Notes that a message reached a spawned session, which then owes its parent a report, unless `inbox` knows it for one of
64
- * `UNOWED` (`spawn` notes the task itself); reads the reverse index alone, and claims nothing: the note is the same twice.
65
- */
66
63
  export async function notePrompt(ports, event, inbox) {
67
64
  const { sessionID, inboxID } = event.data;
68
- // Kept, not deleted: the hub's other subscription looks it up for the same delivery.
69
- if (inboxID !== undefined && UNOWED.has(inbox?.get(inboxID) ?? ""))
65
+ const kind = inboxID === undefined ? undefined : inbox?.get(inboxID);
66
+ if (UNOWED.has(kind ?? ""))
70
67
  return false;
71
68
  const children = childStore(ports.storage);
72
69
  const parentID = await children.indexedParent(sessionID);
73
70
  if (parentID === undefined)
74
71
  return false;
75
- // The reminder to report is not a prompt: it asks for the report the last one owes.
72
+ if (kind === GROUP && (await reportedSincePrompt(children, sessionID)))
73
+ return false;
74
+ // the idle hook's reminder still publishes inbox.delivered (api-notes)
76
75
  if (inboxID !== undefined && (await children.nudgeOf(sessionID)).inboxID === inboxID)
77
76
  return false;
78
77
  await children.prompted(sessionID, event.created ?? ports.now());
79
78
  await syncStanding(ports, parentID);
80
79
  return true;
81
80
  }
82
- /**
83
- * Notes that a spawned session's turn was stopped, which takes it out of its group without a report; a
84
- * shutdown's is left to `reportDeclined`, as one stopped between steps resumes on the next start.
85
- */
86
81
  export async function noteInterrupted(ports, event) {
87
82
  if (event.data.reason === "shutdown")
88
83
  return false;
@@ -99,10 +94,6 @@ export async function noteInterrupted(ports, event) {
99
94
  await syncStanding(ports, entry.parentID);
100
95
  return true;
101
96
  }
102
- /**
103
- * Forgets what a deleted session leaves (report state, groups) and, unless isolated or a parent on the roster, its
104
- * entry. Returns the sessions forgotten.
105
- */
106
97
  export async function noteDeleted(ports, event) {
107
98
  const { sessionID } = event.data;
108
99
  const children = childStore(ports.storage);
@@ -117,33 +108,23 @@ export async function noteDeleted(ports, event) {
117
108
  await syncStanding(ports, parentID);
118
109
  return forgotten;
119
110
  }
120
- /** A `Waits` asking `suppliers` one after another, cheapest first: the first that waits answers. */
121
111
  export const waitsOn = (...suppliers) => async (ports, sessionID, since) => {
122
112
  for (const supplier of suppliers)
123
113
  if (await supplier(ports, sessionID, since))
124
114
  return true;
125
115
  return false;
126
116
  };
127
- /** A `Waits` asking `suppliers` at once, as cheap as each other: it waits when one does, and fails when one fails. */
128
- export const together = (...suppliers) => async (ports, sessionID, since) => (await Promise.all(suppliers.map((supplier) => supplier(ports, sessionID, since)))).includes(true);
129
- /**
130
- * Tells the parent of a spawned session whose turn ended without reporting to it since its last
131
- * prompt, unless it waits; finds the session by the reverse index alone. Returns the parents told.
132
- */
133
117
  export async function reportSilent(ports, seen, event, outside) {
134
118
  const { sessionID } = event.data;
135
119
  const children = childStore(ports.storage);
136
120
  const entry = await children.indexedEntry(sessionID);
137
- // Claimed after the lookup, so other sessions' turns do not crowd `seen`, and before anything is told.
138
121
  if (!entry || !claim(seen, event.id))
139
122
  return [];
140
123
  const report = await owedUnwaited(ports, children, outside, sessionID);
141
124
  if (!report)
142
125
  return [];
143
- // The notice goes out without the reply rather than not at all.
144
126
  const lastText = await ports.session.context({ sessionID }).then(lastReply, () => undefined);
145
127
  const group = (await outIn(ports, entry))?.group;
146
- // Before the notice, which may end the parent's turn at once: once told, it no longer waits for this one.
147
128
  const toldAt = event.created ?? ports.now();
148
129
  await children.told(sessionID, toldAt).catch((error) => ports.log(`courier watch: could not note that the parent of ${sessionID} was told: ${String(error)}`));
149
130
  try {
@@ -156,17 +137,13 @@ export async function reportSilent(ports, seen, event, outside) {
156
137
  });
157
138
  }
158
139
  catch (error) {
159
- // Not told after all: the parent still waits for it.
160
140
  await children.untold(sessionID, toldAt).catch(() => undefined);
161
141
  throw error;
162
142
  }
163
143
  await syncStanding(ports, entry.parentID);
164
144
  return [entry.parentID];
165
145
  }
166
- /**
167
- * A session's turn that OpenCode reports as a shutdown ended in a plain decline, if its transcript ends on an aborted step: its
168
- * parent is told as for a turn that succeeded without a report. A real shutdown during a step reads the same, and is told too.
169
- */
146
+ // a plain decline ends as a shutdown, on an aborted step (api-notes)
170
147
  export async function reportDeclined(ports, seen, event, outside) {
171
148
  if (event.data.reason !== "shutdown" || (await childStore(ports.storage).indexedParent(event.data.sessionID)) === undefined)
172
149
  return [];
@@ -175,7 +152,6 @@ export async function reportDeclined(ports, seen, event, outside) {
175
152
  return reportSilent(ports, seen, event, outside);
176
153
  }
177
154
  const TURN_ENDS = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"]);
178
- /** What `route` and `after` dispatch on; the wake watch needs `inbox.enqueued` and `execution.started` both. */
179
155
  const TREE_TYPES = [
180
156
  "session.execution.started",
181
157
  "session.execution.succeeded",
@@ -186,11 +162,11 @@ const TREE_TYPES = [
186
162
  "session.inbox.cancelled",
187
163
  "session.deleted",
188
164
  ];
189
- /** A session's turn ended, sealing its groups: one that is complete has the scheduler deliver it now; not a shutdown's, which stamps no idle time. */
190
165
  async function sealGroups(ports, event) {
191
- if (event.data.reason === "shutdown")
192
- return;
193
166
  try {
167
+ // a shutdown ends a turn only on a plain decline's aborted step (api-notes)
168
+ if (event.data.reason === "shutdown" && !endsIdle(await ports.session.context({ sessionID: event.data.sessionID }).catch(() => [])))
169
+ return;
194
170
  if (await hasCompleteGroup(groupStore(ports.storage), event.data.sessionID))
195
171
  ports.nudge();
196
172
  }
@@ -198,7 +174,6 @@ async function sealGroups(ports, event) {
198
174
  ports.log(`courier watch: could not read the groups of ${event.data.sessionID}: ${String(error)}`);
199
175
  }
200
176
  }
201
- /** The events of spawned sessions' turns and inboxes, and their deletion: the tree's. */
202
177
  function routeTree(ports, state, outside, event) {
203
178
  if (event.type === "session.execution.failed")
204
179
  return reportFailure(ports, state.seen, event);
@@ -216,7 +191,6 @@ function routeTree(ports, state, outside, event) {
216
191
  return noteDeleted(ports, event);
217
192
  return undefined;
218
193
  }
219
- /** The events that move a courier message waiting in a session's inbox: enqueued, taken, cancelled, and the turns around it. */
220
194
  function routeWake(ports, event) {
221
195
  if (event.type === "session.inbox.enqueued")
222
196
  return watchWake(ports, event);
@@ -230,7 +204,6 @@ function routeWake(ports, event) {
230
204
  return forgetWakes(ports, event.data.sessionID);
231
205
  return undefined;
232
206
  }
233
- /** The tree's handling of an event alongside the wake watch's, which touches other keys and never fails the tree's. */
234
207
  async function route(ports, state, outside, event) {
235
208
  const watched = routeWake(ports, event)?.catch((error) => ports.log(`courier watch: wake of ${event.type}: ${String(error)}`));
236
209
  try {
@@ -240,15 +213,10 @@ async function route(ports, state, outside, event) {
240
213
  await watched;
241
214
  }
242
215
  }
243
- /**
244
- * Tells parents of a child's failed or silent turn, unless it waits on what `outside` says, and notes
245
- * its prompts, interruptions and deletion; `after` a turn's end, seals the groups of its session.
246
- */
247
216
  export function treeEvents(state, outside) {
248
217
  return {
249
218
  types: TREE_TYPES,
250
219
  handle: (ports, event) => route(ports, state, outside, event),
251
- // After: a silent end is judged while the group the nudge may deliver is still there to wait on.
252
220
  after: (ports, event) => (TURN_ENDS.has(event.type) ? sealGroups(ports, event) : undefined),
253
221
  };
254
222
  }
@@ -1,24 +1,9 @@
1
1
  import type { CleanupPorts, GroupStore, Membership, ReportKeys, RosterEntry, RosterStorage, WatchPorts } from "./ports.js";
2
- /** Drops a member from its group as its roster entry goes, unless its report is held: that still goes to the parent. True when dropped. */
3
2
  export declare function dropMember(groups: GroupStore, parentID: string, group: string, sessionID: string): Promise<boolean>;
4
- /** Drops every group of a parent, whose members' reports can no longer be delivered. */
5
3
  export declare function dropGroups(groups: GroupStore, parentID: string): Promise<void>;
6
- /**
7
- * Forgets a child: its roster entry, its reverse key, its report state and, given its group, its membership. Only
8
- * the entry's removal can fail it: keys left behind lead nowhere, and the next load drops them.
9
- */
10
4
  export declare function forgetChild(storage: RosterStorage, parentID: string, sessionID: string, group?: string): Promise<{
11
5
  droppedFromGroup: boolean;
12
6
  }>;
13
- /**
14
- * Drops a member still out from its group as `dropped`, so the group is released without it, then the roster entry
15
- * and report state. A held report stays with its group. Returns the group it was dropped from.
16
- */
17
7
  export declare function forgetDropped(ports: Pick<CleanupPorts, "storage" | "now" | "nudge">, entry: RosterEntry): Promise<string | undefined>;
18
- /**
19
- * Forgets what a session OpenCode deleted leaves: the report state of `forgotten`, itself and the sessions it started,
20
- * and its groups; `member`, its place in its own group, it leaves at `created`, or now.
21
- */
22
8
  export declare function forgetDeleted(ports: WatchPorts, sessionID: string, forgotten: ReadonlyArray<string>, member: Membership | undefined, created?: number): Promise<void>;
23
- /** Removes report keys of sessions with no roster entry; a key that will not go is left for the next load. */
24
9
  export declare function forgetReports(storage: RosterStorage, keys: ReportKeys, sessions: ReadonlyArray<string>): Promise<void>;
@@ -1,8 +1,5 @@
1
1
  import { leave, markLeft, membershipFor } from "./group.js";
2
2
  import { childStore, groupStore } from "./store.js";
3
- // Forgetting a child and what it leaves behind. Its roster entry and report state go together; its group is another
4
- // aggregate, left best effort and alongside, so a report held there outlives the entry and still reaches the parent.
5
- /** Drops a member from its group as its roster entry goes, unless its report is held: that still goes to the parent. True when dropped. */
6
3
  export async function dropMember(groups, parentID, group, sessionID) {
7
4
  const member = await groups.member(parentID, group, sessionID);
8
5
  if (!member || member.report)
@@ -10,15 +7,10 @@ export async function dropMember(groups, parentID, group, sessionID) {
10
7
  await groups.remove(parentID, group, sessionID);
11
8
  return true;
12
9
  }
13
- /** Drops every group of a parent, whose members' reports can no longer be delivered. */
14
10
  export async function dropGroups(groups, parentID) {
15
11
  const members = await groups.ofParent(parentID);
16
12
  await Promise.all(members.map((member) => groups.remove(parentID, member.group, member.sessionID)));
17
13
  }
18
- /**
19
- * Forgets a child: its roster entry, its reverse key, its report state and, given its group, its membership. Only
20
- * the entry's removal can fail it: keys left behind lead nowhere, and the next load drops them.
21
- */
22
14
  export async function forgetChild(storage, parentID, sessionID, group) {
23
15
  const [, dropped] = await Promise.all([
24
16
  childStore(storage).remove(parentID, sessionID),
@@ -26,10 +18,6 @@ export async function forgetChild(storage, parentID, sessionID, group) {
26
18
  ]);
27
19
  return { droppedFromGroup: dropped };
28
20
  }
29
- /**
30
- * Drops a member still out from its group as `dropped`, so the group is released without it, then the roster entry
31
- * and report state. A held report stays with its group. Returns the group it was dropped from.
32
- */
33
21
  export async function forgetDropped(ports, entry) {
34
22
  const groups = groupStore(ports.storage);
35
23
  const member = await membershipFor(groups, entry);
@@ -38,13 +26,8 @@ export async function forgetDropped(ports, entry) {
38
26
  await forgetChild(ports.storage, entry.parentID, entry.sessionID);
39
27
  return member?.out ? member.group : undefined;
40
28
  }
41
- /**
42
- * Forgets what a session OpenCode deleted leaves: the report state of `forgotten`, itself and the sessions it started,
43
- * and its groups; `member`, its place in its own group, it leaves at `created`, or now.
44
- */
45
29
  export async function forgetDeleted(ports, sessionID, forgotten, member, created) {
46
30
  const children = childStore(ports.storage);
47
- // Its groups go whether or not it has children on the roster: a held report outlives the member's entry.
48
31
  const [completed] = await Promise.all([
49
32
  member ? leave(ports, member, "deleted", created ?? ports.now()) : Promise.resolve(false),
50
33
  ...forgotten.map((id) => children.forgetReport(id)),
@@ -53,7 +36,6 @@ export async function forgetDeleted(ports, sessionID, forgotten, member, created
53
36
  if (completed)
54
37
  ports.nudge();
55
38
  }
56
- /** Removes report keys of sessions with no roster entry; a key that will not go is left for the next load. */
57
39
  export async function forgetReports(storage, keys, sessions) {
58
40
  await childStore(storage).dropReported(sessions.flatMap((sessionID) => keys.get(sessionID) ?? []));
59
41
  }
@@ -1,25 +1,19 @@
1
1
  import { type HeldReport, type LeftMember, type RosterEntry } from "./domain.js";
2
2
  import type { GroupPorts, GroupStore, Membership, WatchPorts } from "./ports.js";
3
- /** Makes a spawned session a member of its parent's group, opening the group if it was not. */
4
3
  export declare function joinGroup(groups: GroupStore, parentID: string, group: string, sessionID: string, title: string, at: number): Promise<void>;
5
- /** Whether a parent has a complete group with a report in it, which the scheduler delivers once the parent's turn has ended. */
6
4
  export declare function hasCompleteGroup(groups: GroupStore, parentID: string): Promise<boolean>;
7
- /** Holds a member's report with its group, in place of an earlier one, or of how it had left. */
8
5
  export declare function holdReport(groups: GroupStore, membership: Membership, report: HeldReport): Promise<void>;
9
- /** How many members of a group have reported and how many have not left, and whether the group is complete. */
10
6
  export declare function groupStanding(groups: GroupStore, parentID: string, group: string): Promise<{
11
7
  reported: number;
12
8
  members: number;
13
9
  complete: boolean;
14
10
  }>;
15
- /** `groupStanding` of each open group of a parent, by name, from one read of its members. */
16
11
  export declare function groupStandings(groups: Pick<GroupStore, "ofParent">, parentID: string): Promise<{
17
12
  reported: number;
18
13
  members: number;
19
14
  complete: boolean;
20
15
  name: string;
21
16
  }[]>;
22
- /** A session's membership of a parent's group, if it is a member; `out` says whether the group still waits for its report. */
23
17
  export declare function membershipOf(groups: Pick<GroupStore, "member">, parentID: string, group: string, sessionID: string): Promise<{
24
18
  parentID: string;
25
19
  group: string;
@@ -33,7 +27,6 @@ export declare function membershipOf(groups: Pick<GroupStore, "member">, parentI
33
27
  readonly by: LeftMember["by"];
34
28
  };
35
29
  } | undefined>;
36
- /** A child's membership of the group its roster entry names; none without one. A failed read is the caller's to handle. */
37
30
  export declare function membershipFor(groups: Pick<GroupStore, "member">, entry: RosterEntry): Promise<{
38
31
  parentID: string;
39
32
  group: string;
@@ -47,14 +40,8 @@ export declare function membershipFor(groups: Pick<GroupStore, "member">, entry:
47
40
  readonly by: LeftMember["by"];
48
41
  };
49
42
  } | undefined>;
50
- /**
51
- * Notes that a member left its group without a report: its turn failed or was interrupted, it was deleted or dropped. Read
52
- * again first: one whose report is held meanwhile keeps it, one that left keeps why. True when that completes the group.
53
- */
54
43
  export declare function markLeft(groups: GroupStore, membership: Membership, by: LeftMember["by"], at: number): Promise<boolean>;
55
- /** `markLeft` for a member not read yet; undefined when it is in no open group. */
56
44
  export declare function leaveGroup(groups: GroupStore, parentID: string, group: string, sessionID: string, by: LeftMember["by"], at: number): Promise<boolean | undefined>;
57
- /** A child's membership of an open group it is still out in: the group waits for it, and its end takes it out without a report. */
58
45
  export declare function outIn(ports: Pick<WatchPorts, "storage" | "log">, entry: RosterEntry): Promise<{
59
46
  parentID: string;
60
47
  group: string;
@@ -68,15 +55,6 @@ export declare function outIn(ports: Pick<WatchPorts, "storage" | "log">, entry:
68
55
  readonly by: LeftMember["by"];
69
56
  };
70
57
  } | undefined>;
71
- /** Takes a member out of its group without a report; true when that completes the group, for the caller to nudge the scheduler. */
72
58
  export declare function leave(ports: Pick<WatchPorts, "storage" | "log">, member: Membership, by: "failed" | "interrupted" | "deleted", at: number): Promise<boolean>;
73
- /**
74
- * Puts a member whose report is held back among those out: its latest word to the parent was a blocked report. Read
75
- * again first: a group released meanwhile is not opened again, and a report held since is kept.
76
- */
77
59
  export declare function unholdForBlocked(groups: GroupStore, membership: Membership): Promise<void>;
78
- /**
79
- * Delivers every released group once, from the scheduler's tick: claimed in the shared `claimed`, re-read,
80
- * delivered and only then dropped, so a failed delivery is tried again at the next tick, and a crash in between delivers it again.
81
- */
82
60
  export declare function deliverReleased(ports: GroupPorts, claimed: Set<string>): Promise<void>;