opencode-courier 0.13.8 → 0.14.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 +6 -53
  4. package/dist/kernel/protocol.d.ts +0 -14
  5. package/dist/kernel/protocol.js +1 -15
  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 +0 -8
  16. package/dist/memory/lessons.js +0 -9
  17. package/dist/memory/note.d.ts +0 -9
  18. package/dist/memory/note.js +0 -5
  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 +0 -25
  35. package/dist/memory/text.js +6 -36
  36. package/dist/memory/tools.d.ts +0 -2
  37. package/dist/memory/tools.js +0 -2
  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 +1 -3
  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 +1 -5
  92. package/dist/skill.d.ts +1 -9
  93. package/dist/skill.js +4 -12
  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 +0 -8
  98. package/dist/tree/domain.d.ts +0 -64
  99. package/dist/tree/domain.js +7 -45
  100. package/dist/tree/events.d.ts +0 -36
  101. package/dist/tree/events.js +4 -49
  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 +1 -37
  106. package/dist/tree/index.d.ts +2 -2
  107. package/dist/tree/index.js +1 -3
  108. package/dist/tree/limits.d.ts +0 -4
  109. package/dist/tree/limits.js +1 -12
  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 +2 -138
  119. package/dist/tree/report.d.ts +0 -10
  120. package/dist/tree/report.js +0 -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 +0 -7
  132. package/dist/tree/spawn.js +6 -47
  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 +1 -44
  146. package/dist/tree/text.js +5 -65
  147. package/dist/tree/tools.d.ts +0 -6
  148. package/dist/tree/tools.js +4 -8
  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 +22 -8
  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
package/dist/tree/send.js CHANGED
@@ -4,17 +4,13 @@ import { syncStanding } from "./standing.js";
4
4
  import { childStore, groupStore } from "./store.js";
5
5
  import { reportBody } from "./text.js";
6
6
  import { noteSent } from "./wake.js";
7
- /**
8
- * Drops a message into another session's inbox; OpenCode wakes that session if it is idle. From a spawned session
9
- * to the one that started it, it is its report or its progress (`noteReport`); a group member's final report is held (`hold`).
10
- */
11
7
  export async function send(ports, from, input) {
12
8
  const { status, dropped } = await statusFor(ports, from, input);
13
- // Noted first: the delivery may wake the parent, whose turn may end before a later note.
9
+ // the delivery may wake the parent, whose turn may end before a later note
14
10
  const noted = await noteReport(ports, from, input.sessionID, status).catch(() => undefined);
15
11
  try {
16
12
  const held = noted?.member && status && status !== "blocked" ? await hold(ports, noted.member, { at: noted.at, status, message: input.message, ...(input.artifacts ? { artifacts: input.artifacts } : {}) }) : undefined;
17
- // The parent's standing before the delivery, which may wake it: its first step then shows the report or progress noted.
13
+ // an entry put now shows at the woken parent's next step (api-notes)
18
14
  if (noted)
19
15
  await syncStanding(ports, input.sessionID);
20
16
  if (held)
@@ -24,13 +20,11 @@ export async function send(ports, from, input) {
24
20
  }
25
21
  catch (error) {
26
22
  await noted?.undo().catch(() => undefined);
27
- // The standing as before the note, once the note is taken back.
28
23
  if (noted)
29
24
  await syncStanding(ports, input.sessionID);
30
25
  throw error;
31
26
  }
32
27
  }
33
- /** Delivers the message, watches it, and takes a held report back after a blocked one; the message id and status delivered. */
34
28
  async function deliver(ports, from, input, status, noted) {
35
29
  const text = envelope(from, reportBody(input.message, input.artifacts), status ? { status } : {});
36
30
  const description = `Message from ${from}`;
@@ -41,19 +35,14 @@ async function deliver(ports, from, input, status, noted) {
41
35
  metadata: { source: "courier", from, ...(status ? { status } : {}) },
42
36
  delivery: input.queue ? "queue" : "steer",
43
37
  });
44
- // Watched now, not when OpenCode's enqueued event is handled: a spawn in the same step must count the child it wakes.
38
+ // the enqueued event is handled apart from this call (api-notes)
45
39
  await noteSent(ports, { sessionID: input.sessionID, inboxID: delivered.id, text, description }).catch(() => undefined);
46
- // A blocked report, once delivered, is the member's latest word: an earlier report held for it would be stale.
47
40
  if (noted?.member && status === "blocked")
48
41
  await unholdForBlocked(groupStore(ports.storage), noted.member).catch(() => undefined);
49
42
  return { messageID: delivered.id, ...(status ? { status } : {}) };
50
43
  }
51
- /**
52
- * The message's status, checked, as OpenCode does not check the schema. A status is a report's: on a message to a
53
- * session below the sender it means nothing, so it is `dropped`, and the result says so.
54
- */
44
+ // tool inputs use plain schemas, so the value is checked here (api-notes)
55
45
  async function statusFor(ports, from, input) {
56
- // Some models send null for an optional field they leave out.
57
46
  const given = input.status ?? undefined;
58
47
  if (given === undefined)
59
48
  return {};
@@ -61,15 +50,10 @@ async function statusFor(ports, from, input) {
61
50
  throw new Error(`status must be one of ${STATUSES.join(", ")}, not ${JSON.stringify(given)}.`);
62
51
  return (await isBelow(ports, from, input.sessionID)) ? { dropped: given } : { status: given };
63
52
  }
64
- /** Whether `from` started `to`, directly or through others, by the reverse index; false when the index does not name `to` or cannot be read. */
65
53
  async function isBelow(ports, from, to) {
66
54
  const above = await childStore(ports.storage).indexedAncestors(to).catch(() => []);
67
55
  return above.includes(from);
68
56
  }
69
- /**
70
- * Notes `from`'s message to `to` when `to` started it: as its report when it carries a status, or when an earlier
71
- * release briefed `from`, else as progress. Returns which, when, how to take the note back, and `from`'s group membership.
72
- */
73
57
  async function noteReport(ports, from, to, status) {
74
58
  const children = childStore(ports.storage);
75
59
  const entry = await children.indexedEntry(from);
@@ -80,7 +64,6 @@ async function noteReport(ports, from, to, status) {
80
64
  const before = await children.peek(from, field);
81
65
  const at = ports.now();
82
66
  await (report ? children.settled(from, "report", at, status) : children.progressed(from, at));
83
- // Only its own note: another courier_send of the session's may have noted its report since.
84
67
  const undo = async () => {
85
68
  const now = (await children.peek(from, field));
86
69
  if (now?.at !== at || (report && now.by !== "report"))
@@ -89,10 +72,6 @@ async function noteReport(ports, from, to, status) {
89
72
  };
90
73
  return { report, at, undo, member: status !== undefined ? await memberIn(ports, entry) : undefined };
91
74
  }
92
- /**
93
- * A child's membership of an open group of its parent's, by its roster entry; none once courier_cleanup dropped it. One that
94
- * cannot be read is dropped from the group, best effort, as the report then goes on its own and the group must not wait for it.
95
- */
96
75
  async function memberIn(ports, entry) {
97
76
  const groups = groupStore(ports.storage);
98
77
  try {
@@ -106,24 +85,18 @@ async function memberIn(ports, entry) {
106
85
  return undefined;
107
86
  }
108
87
  }
109
- /**
110
- * Holds a member's report with its group, and nudges the scheduler if that completes the group. Undefined
111
- * when it could not be held: the member is dropped from the group, which releases without it, and reports on its own.
112
- */
113
88
  async function hold(ports, member, report) {
114
89
  const groups = groupStore(ports.storage);
115
90
  try {
116
91
  await holdReport(groups, member, report);
117
92
  }
118
93
  catch (error) {
119
- // Neither held nor dropped, the group would wait for ever: the call fails, and the sender sends it again.
120
94
  await groups.remove(member.parentID, member.group, member.sessionID).catch((dropError) => {
121
95
  throw new Error(`the report could not be held with group ${member.group} (${String(error)}), nor the member dropped from it (${String(dropError)}); send it again.`);
122
96
  });
123
97
  ports.nudge();
124
98
  return undefined;
125
99
  }
126
- // Held whatever the count gives; a count that cannot be read gets the nudge, as a tick that finds nothing due is cheap.
127
100
  const standing = await groupStanding(groups, member.parentID, member.group).catch(() => undefined);
128
101
  if (standing?.complete !== false)
129
102
  ports.nudge();
@@ -5,17 +5,10 @@ export interface SpawnInput {
5
5
  readonly agent?: string;
6
6
  readonly isolate?: boolean;
7
7
  readonly group?: string;
8
- /** `provider/model` to run on; left out, the agent's own model, else the caller's. */
9
8
  readonly model?: string;
10
- /** The variant of that model, its reasoning effort; left out, the chosen model's own. */
11
9
  readonly variant?: string;
12
- /** Whether the child is OpenCode's subagent of the caller; left out, the `nativeChildren` option decides. */
13
10
  readonly native?: boolean;
14
11
  }
15
- /**
16
- * Creates a child session, hands it the task and returns at once; the child reports back with
17
- * courier_send. Refused past a limit, whether or not the `context` hook hid the tool.
18
- */
19
12
  export declare function spawn(ports: CourierPorts, parentID: string, input: SpawnInput): Promise<{
20
13
  native?: boolean | undefined;
21
14
  model?: string | undefined;
@@ -6,30 +6,19 @@ import { chooseModel } from "./model.js";
6
6
  import { putEntries, syncStanding } from "./standing.js";
7
7
  import { childStore, groupStore } from "./store.js";
8
8
  import { childBrief, childBranch, nativeIsolateRefusal, ROLE_KEY, rolePart } from "./text.js";
9
- /**
10
- * A spawned session's own rules: OpenCode's schedule tools are hidden, as their wake-ups go round the tree, so a child
11
- * that schedules one and ends its turn would read as silent. A native child would otherwise run on its parent's rules.
12
- */
9
+ // a schedule's wake-up is invisible to Waits (api-notes, OpenCode's schedules)
13
10
  const CHILD_RULES = [{ action: "opencode_schedule_*", resource: "*", effect: "deny" }];
14
11
  function titleOf(task) {
15
12
  const line = task.trim().split("\n")[0] ?? "";
16
13
  return line.length > 60 ? `${line.slice(0, 57)}...` : line;
17
14
  }
18
- /** Removes a worktree made for a child that was never created; one that cannot be removed stays. */
19
15
  async function dropWorktree(ports, directory) {
20
16
  try {
21
17
  await ports.worktree.remove({ projectID: ports.projectID, directory, force: false });
22
18
  }
23
- catch {
24
- // The spawn's own failure is the one to report.
25
- }
19
+ catch { }
26
20
  }
27
- /**
28
- * Creates a child session, hands it the task and returns at once; the child reports back with
29
- * courier_send. Refused past a limit, whether or not the `context` hook hid the tool.
30
- */
31
21
  export async function spawn(ports, parentID, input) {
32
- // Some models send null, or an empty string, for an optional field they leave out.
33
22
  const group = typeof input.group === "string" ? input.group.trim() || undefined : (input.group ?? undefined);
34
23
  if (group !== undefined && !isGroupName(group))
35
24
  throw new Error(`group must be a name of 1 to 60 letters, digits, dots, dashes or underscores, not ${JSON.stringify(group)}.`);
@@ -63,7 +52,6 @@ async function start(ports, parentID, input, admitted, group) {
63
52
  await handOver(ports, entry, admitted.depth, input.task, !rosterError, !rosterError && !groupError);
64
53
  }
65
54
  finally {
66
- // The parent's standing with the child on it, prompted or failed, announced at its next step.
67
55
  await syncStanding(ports, parentID);
68
56
  }
69
57
  return {
@@ -78,10 +66,6 @@ async function start(ports, parentID, input, admitted, group) {
78
66
  ...(entry.native ? { native: true } : {}),
79
67
  };
80
68
  }
81
- /**
82
- * Creates the child's session, in a worktree of its own if asked, which goes again if the session cannot be created.
83
- * `model` names the child's model when it is not its parent's, for the roster and the result.
84
- */
85
69
  async function create(ports, parentID, input) {
86
70
  const { model, parent } = await chooseModel(ports, parentID, input, input.agent);
87
71
  const handedDown = input.isolate ? await parentWork(ports, parentID) : undefined;
@@ -90,7 +74,7 @@ async function create(ports, parentID, input) {
90
74
  : undefined;
91
75
  const base = worktree ? await ports.head(worktree) : undefined;
92
76
  const title = input.title ?? titleOf(input.task);
93
- // OpenCode takes a location or a parentID, never both, so an isolated child cannot be a native one.
77
+ // OpenCode takes a location or a parentID, never both
94
78
  const native = (input.native ?? ports.nativeChildren === true) && !worktree;
95
79
  const child = await ports.session
96
80
  .create({
@@ -103,7 +87,6 @@ async function create(ports, parentID, input) {
103
87
  metadata: { courier: { parentID } },
104
88
  })
105
89
  .catch(async (error) => {
106
- // No session will ever use the fresh worktree, and nothing records it, so it goes now.
107
90
  if (worktree)
108
91
  await dropWorktree(ports, worktree);
109
92
  throw error;
@@ -119,10 +102,6 @@ async function create(ports, parentID, input) {
119
102
  ...(model && (!parent || modelName(model) !== modelName(parent)) ? { model: modelName(model) } : {}),
120
103
  };
121
104
  }
122
- /**
123
- * The worktree and HEAD an isolated parent hands its isolated child, by the reverse index alone; undefined for any
124
- * other parent. An unreadable HEAD fails the spawn rather than start the child without the parent's work.
125
- */
126
105
  async function parentWork(ports, parentID) {
127
106
  const parent = await childStore(ports.storage).indexedEntry(parentID);
128
107
  if (!parent?.isolated)
@@ -133,21 +112,13 @@ async function parentWork(ports, parentID) {
133
112
  const uncommitted = (await ports.dirty(parent.directory).catch(() => undefined)) === true;
134
113
  return { directory: parent.directory, head, uncommitted };
135
114
  }
136
- /**
137
- * Records the child on the roster, before the prompt, so a child that exists is on the roster even if prompting
138
- * fails, then in its group. A failed write must not keep the child from its task: each is reported, not thrown.
139
- */
140
115
  async function enrol(ports, entry, group) {
141
116
  const rosterError = await childStore(ports.storage).record(entry).then(() => undefined, (error) => describeFailure("roster", error).message);
142
117
  const groupError = group && !rosterError ? await join(ports, entry, group) : undefined;
143
118
  return { rosterError, groupError };
144
119
  }
145
- /**
146
- * Hands the child its task. `rostered` says whether its report state is kept, which a child off the roster has none
147
- * of, and `grouped` whether it is in its group; a child that never got its task leaves both, as it will never report.
148
- */
149
120
  async function handOver(ports, entry, depth, task, rostered, grouped) {
150
- // A baseline, until OpenCode's event for the prompt's delivery moves it on; written first, so it never overtakes that.
121
+ // the prompt's inbox.delivered event moves this on; so written first
151
122
  const children = childStore(ports.storage);
152
123
  if (rostered)
153
124
  await children.prompted(entry.sessionID, ports.now()).catch(() => undefined);
@@ -162,10 +133,6 @@ async function handOver(ports, entry, depth, task, rostered, grouped) {
162
133
  throw error;
163
134
  }
164
135
  }
165
- /**
166
- * Joins a child to its group; one that cannot be joined is recorded in no group, so it reports on its own,
167
- * and the result says so. Returns the error's message, if any.
168
- */
169
136
  async function join(ports, entry, group) {
170
137
  const groups = groupStore(ports.storage);
171
138
  try {
@@ -174,7 +141,6 @@ async function join(ports, entry, group) {
174
141
  }
175
142
  catch (error) {
176
143
  const { group: _, ...ungrouped } = entry;
177
- // A write that failed may have landed all the same: a member left there would hold its group up for good.
178
144
  await Promise.all([
179
145
  childStore(ports.storage).record(ungrouped).catch(() => undefined),
180
146
  groups.remove(entry.parentID, group, entry.sessionID).catch(() => undefined),
@@ -182,10 +148,7 @@ async function join(ports, entry, group) {
182
148
  return describeFailure("group", error).message;
183
149
  }
184
150
  }
185
- /**
186
- * The child's role and the other modules' entries before its prompt, so its first request carries them, and a root's own at
187
- * its first spawn, remembered for the `context` hook; a parent that is no root is forgotten there, as its depth may have changed.
188
- */
151
+ // entries put before the first step are in its baseline (api-notes)
189
152
  async function nameBoth(ports, parentID, childID, depth) {
190
153
  const extra = await extraEntries(ports);
191
154
  await nameRole(ports, childID, depth);
@@ -200,7 +163,6 @@ async function nameBoth(ports, parentID, childID, depth) {
200
163
  else
201
164
  ports.roles.delete(parentID);
202
165
  }
203
- /** The entries the other modules give a spawned session, the memory's lessons; none when they cannot be read, logged. */
204
166
  async function extraEntries(ports) {
205
167
  if (!ports.entries || !ports.session.instructions)
206
168
  return [];
@@ -212,10 +174,7 @@ async function extraEntries(ports) {
212
174
  return [];
213
175
  }
214
176
  }
215
- /**
216
- * Writes a session's role part to its `role` entry: a child's system prompt carries it from its first request, a root's
217
- * transcript as an update. False on a host without entries, or when the put failed, logged: the `context` hook adds the part.
218
- */
177
+ // a host before 2.2.0 has no entries; the context hook adds the part
219
178
  async function nameRole(ports, sessionID, depth) {
220
179
  const entries = ports.session.instructions?.entry;
221
180
  if (!entries)
@@ -1,17 +1,9 @@
1
1
  import type { Entry, ReportNote, RosterStorage, StandingPorts } from "./ports.js";
2
2
  import { type ChildState, type StandingRow } from "./text.js";
3
- /** A child's state from its report notes, as `reportOf` and the silent-turn rule read them. */
4
3
  export declare function stateOf(notes: {
5
4
  readonly [K in "prompt" | "settled" | "progress" | "told" | "stopped"]: ReportNote;
6
5
  }): ChildState;
7
- /** The rows of a parent's standing entry: its unexpired children, oldest first, each with its state and group standing. */
8
6
  export declare function standingRows(storage: RosterStorage, parentID: string, now: number): Promise<StandingRow[]>;
9
- /** What `syncStanding` did to the parent's entry. */
10
7
  export type Synced = "put" | "unchanged" | "removed" | "absent" | "skipped";
11
- /**
12
- * Writes a parent's `standing` entry once something of it changed: put when its text differs from the entry's, removed when
13
- * the parent has no children left; skipped without the keyed entries. A failure is logged, never thrown.
14
- */
15
8
  export declare function syncStanding(ports: StandingPorts, parentID: string): Promise<Synced>;
16
- /** Writes `given` entries to a session, each put logged on failure and never thrown: the session gets its task all the same. */
17
9
  export declare function putEntries(ports: StandingPorts, sessionID: string, given: ReadonlyArray<Entry>): Promise<void>;
@@ -4,9 +4,7 @@ import { isNotFound } from "../platform/json.js";
4
4
  import { isExpired, standingOf } from "./domain.js";
5
5
  import { childStore, groupStore } from "./store.js";
6
6
  import { STANDING_KEY, standingEntry } from "./text.js";
7
- /** Whether `at` is known and no earlier than the prompt at `prompt`. */
8
7
  const since = (at, prompt) => at !== undefined && at >= prompt;
9
- /** A child's state from its report notes, as `reportOf` and the silent-turn rule read them. */
10
8
  export function stateOf(notes) {
11
9
  const prompt = notes.prompt.at;
12
10
  if (notes.stopped.at !== undefined && (prompt === undefined || notes.stopped.at >= prompt))
@@ -21,10 +19,8 @@ export function stateOf(notes) {
21
19
  return { kind: by };
22
20
  return { kind: "reported", status: isStatus(status) ? status : "done", ...progressed };
23
21
  }
24
- /** The rows of a parent's standing entry: its unexpired children, oldest first, each with its state and group standing. */
25
22
  export async function standingRows(storage, parentID, now) {
26
23
  const children = childStore(storage);
27
- // The groups read best effort, as courier_children reads them: a member's standing is `unknown` when they cannot be.
28
24
  const [entries, members] = await Promise.all([children.children(parentID), groupStore(storage).ofParent(parentID).catch(() => undefined)]);
29
25
  const current = entries.filter((entry) => !isExpired(entry, now, existsSync));
30
26
  return Promise.all(current.map(async (entry) => {
@@ -39,12 +35,7 @@ export async function standingRows(storage, parentID, now) {
39
35
  };
40
36
  }));
41
37
  }
42
- /** The syncs under way in this process, by parent, each run after the one before it: a sync that read earlier never writes last. */
43
38
  const inTurn = new Map();
44
- /**
45
- * Writes a parent's `standing` entry once something of it changed: put when its text differs from the entry's, removed when
46
- * the parent has no children left; skipped without the keyed entries. A failure is logged, never thrown.
47
- */
48
39
  export function syncStanding(ports, parentID) {
49
40
  const entries = ports.session?.instructions?.entry;
50
41
  if (!entries)
@@ -75,13 +66,12 @@ async function sync(ports, parentID, entries) {
75
66
  return "put";
76
67
  }
77
68
  catch (error) {
78
- // A parent OpenCode no longer knows has no entry to keep: a child deleted after it is a normal path.
69
+ // a put on a session OpenCode deleted fails NotFound (api-notes)
79
70
  if (!isNotFound(error))
80
71
  ports.log?.(`courier: the standing entry of ${parentID} could not be written, so it may be behind courier_children: ${String(error)}`);
81
72
  return "skipped";
82
73
  }
83
74
  }
84
- /** Writes `given` entries to a session, each put logged on failure and never thrown: the session gets its task all the same. */
85
75
  export async function putEntries(ports, sessionID, given) {
86
76
  const entries = ports.session?.instructions?.entry;
87
77
  if (!entries)
@@ -7,7 +7,6 @@ export interface StatusInput {
7
7
  export interface ChildrenInput {
8
8
  readonly sessionID?: string;
9
9
  }
10
- /** One session's state and last reply, as courier_children shows each child. */
11
10
  export declare function status(ports: CourierPorts, input: StatusInput): Promise<Partial<{
12
11
  sessionID: string;
13
12
  title: string | undefined;
@@ -19,12 +18,7 @@ export declare function status(ports: CourierPorts, input: StatusInput): Promise
19
18
  lastText: string | undefined;
20
19
  pending: readonly import("./ports.js").Pending[] | undefined;
21
20
  }>>;
22
- /** The text of a session's last reply, from its context's messages, or undefined when it has none. */
23
21
  export declare function lastReply(messages: Awaited<ReturnType<Context["session"]["context"]>>): string | undefined;
24
- /**
25
- * The sessions a parent started, each with its state and last reply, or the error it gave; a child in a
26
- * group with the group's name and its standing there: `held`, `out`, how it left, `released` or `unknown`.
27
- */
28
22
  export declare function listChildren(ports: CourierPorts, parentID: string): Promise<({
29
23
  group: {
30
24
  name: string;
@@ -84,7 +78,6 @@ export declare function listChildren(ports: CourierPorts, parentID: string): Pro
84
78
  sessionID: string;
85
79
  title: string;
86
80
  })[]>;
87
- /** One roster entry as courier_children shows it: the session's state with its directory and group standing, or the error it gave. */
88
81
  export declare function described(ports: CourierPorts, entry: RosterEntry): Promise<{
89
82
  group: {
90
83
  name: string;
@@ -144,6 +137,5 @@ export declare function described(ports: CourierPorts, entry: RosterEntry): Prom
144
137
  sessionID: string;
145
138
  title: string;
146
139
  }>;
147
- /** A child's standing in the join group its roster entry names, `unknown` when that cannot be read. */
148
140
  export declare function standingIn(ports: Pick<CourierPorts, "storage">, entry: RosterEntry, group: string): Promise<"failed" | "interrupted" | "out" | "unknown" | "deleted" | "dropped" | "held" | "released">;
149
141
  export {};
@@ -2,7 +2,6 @@ import { describeFailure } from "../kernel/protocol.js";
2
2
  import { standingOf, startedAs } from "./domain.js";
3
3
  import { current } from "./roster.js";
4
4
  import { groupStore } from "./store.js";
5
- /** One session's state and last reply, as courier_children shows each child. */
6
5
  export async function status(ports, input) {
7
6
  const [info, messages, pending] = await Promise.all([
8
7
  ports.session.get({ sessionID: input.sessionID }),
@@ -22,22 +21,16 @@ export async function status(ports, input) {
22
21
  pending: pending.length ? pending : undefined,
23
22
  });
24
23
  }
25
- /** The text of a session's last reply, from its context's messages, or undefined when it has none. */
26
24
  export function lastReply(messages) {
27
25
  const last = messages.findLast((message) => message.type === "assistant");
28
26
  return last?.type === "assistant"
29
27
  ? last.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")
30
28
  : undefined;
31
29
  }
32
- /**
33
- * The sessions a parent started, each with its state and last reply, or the error it gave; a child in a
34
- * group with the group's name and its standing there: `held`, `out`, how it left, `released` or `unknown`.
35
- */
36
30
  export async function listChildren(ports, parentID) {
37
31
  const entries = await current(ports.storage, parentID, ports.now());
38
32
  return Promise.all(entries.map((entry) => described(ports, entry)));
39
33
  }
40
- /** One roster entry as courier_children shows it: the session's state with its directory and group standing, or the error it gave. */
41
34
  export async function described(ports, entry) {
42
35
  const group = entry.group ? { group: { name: entry.group, report: await standingIn(ports, entry, entry.group) } } : {};
43
36
  const roster = { directory: entry.directory, ...startedAs(entry), created: entry.createdAt, ...group };
@@ -53,11 +46,10 @@ export async function described(ports, entry) {
53
46
  };
54
47
  }
55
48
  }
56
- /** A child's standing in the join group its roster entry names, `unknown` when that cannot be read. */
57
49
  export function standingIn(ports, entry, group) {
58
50
  return groupStore(ports.storage).member(entry.parentID, group, entry.sessionID).then(standingOf, () => "unknown");
59
51
  }
60
- /** OpenCode leaves a tool call hanging when its metadata holds `undefined`, so results drop those keys. */
52
+ // metadata with an undefined value never completes the call (api-notes)
61
53
  function withoutUndefined(value) {
62
54
  return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
63
55
  }
@@ -3,31 +3,24 @@ export interface StopInput {
3
3
  readonly sessionID: string;
4
4
  readonly cleanup?: boolean;
5
5
  }
6
- /** What happened to one session: its turn was interrupted, it was not running, OpenCode no longer knows it, or the interrupt failed. */
7
6
  export interface Stopped {
8
7
  readonly sessionID: string;
9
8
  readonly title: string;
10
9
  readonly depth: number;
11
10
  readonly outcome: "interrupted" | "idle" | "gone" | "failed";
12
11
  readonly error?: string;
12
+ readonly subagent?: true;
13
13
  }
14
14
  export interface StopResult {
15
15
  readonly sessionID: string;
16
- /** Every session stopped, deepest first; the target is the last. */
17
16
  readonly stopped: ReadonlyArray<Stopped>;
18
- /** The ids of the courier_later messages cancelled. */
19
17
  readonly cancelled: ReadonlyArray<string>;
20
- /** With `cleanup`: what became of each isolated session's worktree, deepest first. */
21
18
  readonly cleanup?: ReadonlyArray<CleanupResult | {
22
19
  readonly sessionID: string;
23
20
  readonly outcome: "failed";
24
21
  readonly error: string;
25
22
  }>;
26
- /** The session told that the target was stopped, when that is not the caller. */
27
23
  readonly told?: string;
28
24
  }
29
- /**
30
- * Stops a session and everything under it, deepest first, and cancels the messages scheduled for them; only a session above the target may.
31
- * The caller is not told; the target's parent is, unless that is the caller.
32
- */
25
+ export declare const SETTLE_MS = 2000;
33
26
  export declare function stop(ports: StopPorts, callerID: string, input: StopInput): Promise<StopResult>;
package/dist/tree/stop.js CHANGED
@@ -6,15 +6,12 @@ import { childStore } from "./store.js";
6
6
  import { subtree } from "./subtree.js";
7
7
  import { stoppedNotice } from "./text.js";
8
8
  import { forgetWakes } from "./wake.js";
9
- /** Rounds of looking again for sessions started while others were being stopped. */
10
9
  const ROUNDS = 3;
11
- /**
12
- * Stops a session and everything under it, deepest first, and cancels the messages scheduled for them; only a session above the target may.
13
- * The caller is not told; the target's parent is, unless that is the caller.
14
- */
10
+ export const SETTLE_MS = 2_000;
15
11
  export async function stop(ports, callerID, input) {
16
- const { storage } = ports.courier;
12
+ const { storage, session } = ports.courier;
17
13
  const { sessionID } = input;
14
+ const startedAt = Date.now();
18
15
  const chain = await childStore(storage).lineage(sessionID);
19
16
  if (!chain.length)
20
17
  throw new Error(`${sessionID} was not started with courier_spawn, so courier_stop cannot stop it.`);
@@ -31,15 +28,17 @@ export async function stop(ports, callerID, input) {
31
28
  for (const node of pending)
32
29
  seen.add(node.entry.sessionID);
33
30
  nodes.push(...pending);
34
- // Before any interrupt, so nothing scheduled wakes a session that was just stopped.
35
- await Promise.all(pending.map((node) => silence(ports, node.entry.sessionID, cancelled)));
31
+ await Promise.all(pending.map((node) => silence(ports, node, cancelled)));
36
32
  results.push(...(await eachDeepestFirst(pending, (node) => interrupt(ports, node))));
37
- const meanwhile = (await subtree(storage, sessionID)).nodes.filter((node) => !seen.has(node.entry.sessionID));
33
+ const meanwhile = (await subtree(storage, sessionID, undefined, session)).nodes.filter((node) => !seen.has(node.entry.sessionID));
38
34
  return round(meanwhile, left - 1);
39
35
  };
40
- await round([{ entry: target, depth: 0 }, ...(await subtree(storage, sessionID)).nodes], ROUNDS);
41
- // Each parent among them reads its stopped children at its next step; the caller's own entry, when it is the target's parent, too.
42
- await Promise.all([...new Set(nodes.map((node) => node.entry.parentID))].map((parentID) => syncStanding(ports.courier, parentID)));
36
+ const first = (await subtree(storage, sessionID, undefined, session)).nodes;
37
+ const background = await backgroundSubagents(ports, first);
38
+ await round([{ entry: target, depth: 0 }, ...first], ROUNDS);
39
+ await settle(ports, nodes, results, background, startedAt);
40
+ const parents = new Set(nodes.flatMap((node) => (node.subagent ? [] : [node.entry.parentID])));
41
+ await Promise.all([...parents].map((parentID) => syncStanding(ports.courier, parentID)));
43
42
  const told = target.parentID !== callerID && results.find((one) => one.sessionID === sessionID)?.outcome === "interrupted"
44
43
  ? await tell(ports, callerID, target)
45
44
  : undefined;
@@ -51,15 +50,15 @@ export async function stop(ports, callerID, input) {
51
50
  ...(told ? { told } : {}),
52
51
  };
53
52
  }
54
- /** Runs `act` on the nodes level by level, the deepest level first and the level's nodes at once; the results in that order. */
55
53
  function eachDeepestFirst(nodes, act) {
56
54
  const depths = [...new Set(nodes.map((node) => node.depth))].sort((a, b) => b - a);
57
55
  return depths.reduce((before, depth) => before.then(async (done) => [...done, ...(await Promise.all(nodes.filter((node) => node.depth === depth).map(act)))]), Promise.resolve([]));
58
56
  }
59
- /** Notes that a session was stopped, so the failure and silent-end notices stay quiet for it, and cancels its pending messages. */
60
- async function silence(ports, sessionID, cancelled) {
57
+ async function silence(ports, { entry, subagent }, cancelled) {
61
58
  const { storage, now, log } = ports.courier;
62
- await childStore(storage).stopped(sessionID, now()).catch((error) => log(`courier_stop: could not note that ${sessionID} was stopped: ${String(error)}`));
59
+ const { sessionID } = entry;
60
+ if (!subagent)
61
+ await childStore(storage).stopped(sessionID, now()).catch((error) => log(`courier_stop: could not note that ${sessionID} was stopped: ${String(error)}`));
63
62
  try {
64
63
  cancelled.push(...(await ports.cancelScheduled(sessionID)));
65
64
  }
@@ -68,23 +67,59 @@ async function silence(ports, sessionID, cancelled) {
68
67
  }
69
68
  await forgetWakes(ports.courier, sessionID).catch((error) => log(`courier_stop: could not drop the wake watch of ${sessionID}: ${String(error)}`));
70
69
  }
71
- async function interrupt(ports, { entry, depth }) {
70
+ async function interrupt(ports, { entry, depth, subagent }) {
72
71
  const { sessionID, title } = entry;
73
72
  const { storage, session, now } = ports.courier;
73
+ const one = { sessionID, title, depth, ...(subagent ? { subagent } : {}) };
74
74
  try {
75
75
  const { interrupted } = await session.interrupt({ sessionID });
76
- // The event for it says so too; noted here as well, so the end is settled when this returns.
77
- if (interrupted)
76
+ // the interrupted event may arrive after this returns
77
+ if (interrupted && !subagent)
78
78
  await childStore(storage).settled(sessionID, "interrupted", now()).catch(() => undefined);
79
- return { sessionID, title, depth, outcome: interrupted ? "interrupted" : "idle" };
79
+ return { ...one, outcome: interrupted ? "interrupted" : "idle" };
80
80
  }
81
81
  catch (error) {
82
82
  if (isNotFound(error))
83
- return { sessionID, title, depth, outcome: "gone" };
84
- return { sessionID, title, depth, outcome: "failed", error: describeFailure("session.interrupt", error).message };
83
+ return { ...one, outcome: "gone" };
84
+ return { ...one, outcome: "failed", error: describeFailure("session.interrupt", error).message };
85
85
  }
86
86
  }
87
- /** Tells the target's parent, which is below the caller, that it was stopped; undefined when that could not be done. */
87
+ // a completed background subagent call names its child (api-notes)
88
+ async function backgroundSubagents(ports, nodes) {
89
+ const parents = [...new Set(nodes.flatMap((node) => (node.subagent ? [node.entry.parentID] : [])))];
90
+ const found = await Promise.all(parents.map(async (parentID) => {
91
+ const messages = await ports.courier.session.context({ sessionID: parentID }).catch(() => []);
92
+ const returned = messages.flatMap((message) => message.type === "assistant" ? message.content.flatMap((part) => (part.type === "tool" && part.name === "subagent" && part.state.status === "completed" ? [part] : [])) : []);
93
+ const named = (id) => returned.some((part) => part.state.status === "completed" && JSON.stringify([part.state.content, part.state.metadata ?? null]).includes(id));
94
+ const ids = nodes.filter((node) => node.subagent && node.entry.parentID === parentID && named(node.entry.sessionID)).map((node) => node.entry.sessionID);
95
+ return [parentID, new Set(ids)];
96
+ }));
97
+ return new Map(found.filter(([, ids]) => ids.size));
98
+ }
99
+ // a background subagent's end wakes its parent (api-notes)
100
+ async function settle(ports, nodes, results, background, since) {
101
+ const ended = new Set(results.flatMap((one) => (one.subagent && one.outcome === "interrupted" ? [one.sessionID] : [])));
102
+ await Promise.all([...background].map(async ([parentID, ids]) => {
103
+ const waited = new Set([...ids].filter((id) => ended.has(id)));
104
+ if (!waited.size || !(await told(ports, parentID, waited, since)))
105
+ return;
106
+ const parent = nodes.find((node) => node.entry.sessionID === parentID);
107
+ if (parent && !parent.subagent)
108
+ await childStore(ports.courier.storage).stopped(parentID, ports.courier.now()).catch(() => undefined);
109
+ if (parent)
110
+ await interrupt(ports, parent);
111
+ }));
112
+ }
113
+ async function told(ports, parentID, ids, since) {
114
+ const messages = await ports.courier.session.context({ sessionID: parentID }).catch(() => []);
115
+ const noticed = new Set(messages.flatMap((message) => (message.type === "synthetic" && message.time.created >= since && typeof message.metadata?.childID === "string" ? [message.metadata.childID] : [])));
116
+ if ([...ids].every((id) => noticed.has(id)))
117
+ return true;
118
+ if (Date.now() - since >= SETTLE_MS)
119
+ return false;
120
+ await new Promise((resolve) => setTimeout(resolve, 50));
121
+ return told(ports, parentID, ids, since);
122
+ }
88
123
  async function tell(ports, callerID, target) {
89
124
  const { session, log } = ports.courier;
90
125
  try {
@@ -102,9 +137,7 @@ async function tell(ports, callerID, target) {
102
137
  return undefined;
103
138
  }
104
139
  }
105
- /** Removes the isolated sessions' worktrees, deepest first, through courier_cleanup's checks; a failure is reported, not thrown. */
106
140
  async function removeWorktrees(ports, nodes) {
107
- // One at a time: git locks the repository's worktree list while it removes one.
108
141
  const removeNext = async (queue, done) => {
109
142
  const [next, ...rest] = queue;
110
143
  if (!next)
@@ -1,9 +1,5 @@
1
1
  import { type Storage } from "../platform/storage.js";
2
2
  import type { ChildStore, GroupStore, WakeStore } from "./ports.js";
3
- /**
4
- * What the reverse index holds for a child: the sessions above it, its parent first and the top
5
- * session, which courier_spawn did not start, last; each is the parent of the one before it.
6
- */
7
3
  export interface ReverseEntry {
8
4
  readonly ancestors: ReadonlyArray<string>;
9
5
  }
@@ -16,13 +12,9 @@ export declare const toldKey: (sessionID: string) => string;
16
12
  export declare const stoppedKey: (sessionID: string) => string;
17
13
  export declare const nudgedKey: (sessionID: string) => string;
18
14
  export declare const memberKey: (parentID: string, group: string, sessionID: string) => string;
19
- /** The key prefix of a parent's groups, and of one group's members. */
20
15
  export declare const groupsPrefix: (parentID: string) => string;
21
16
  export declare const groupPrefix: (parentID: string, group: string) => string;
22
17
  export declare const wakeKey: (sessionID: string, inboxID: string) => string;
23
- /** The `ChildStore` over the plugin's storage. */
24
18
  export declare function childStore(storage: Storage): ChildStore;
25
- /** The `GroupStore` over the plugin's storage. */
26
19
  export declare function groupStore(storage: Storage): GroupStore;
27
- /** The `WakeStore` over the plugin's storage. */
28
20
  export declare function wakeStore(storage: Storage): WakeStore;