opencode-courier 0.12.0 → 0.13.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 (48) hide show
  1. package/README.md +9 -8
  2. package/dist/index.js +7 -3
  3. package/dist/kernel/protocol.d.ts +2 -0
  4. package/dist/kernel/protocol.js +4 -0
  5. package/dist/memory/events.js +3 -1
  6. package/dist/memory/index.d.ts +2 -1
  7. package/dist/memory/index.js +2 -1
  8. package/dist/memory/lessons.d.ts +12 -0
  9. package/dist/memory/lessons.js +56 -0
  10. package/dist/memory/note.js +3 -0
  11. package/dist/memory/ports.d.ts +3 -1
  12. package/dist/memory/text.d.ts +11 -0
  13. package/dist/memory/text.js +22 -1
  14. package/dist/person/events.js +4 -1
  15. package/dist/person/permission.d.ts +1 -1
  16. package/dist/person/permission.js +1 -1
  17. package/dist/person/question/answer.d.ts +1 -1
  18. package/dist/person/question/answer.js +1 -1
  19. package/dist/person/question/lifecycle.d.ts +1 -1
  20. package/dist/person/question/lifecycle.js +1 -1
  21. package/dist/person/question/relay.js +1 -1
  22. package/dist/platform/events.d.ts +10 -2
  23. package/dist/platform/events.js +38 -8
  24. package/dist/skill.js +56 -59
  25. package/dist/tree/cleanup.js +2 -0
  26. package/dist/tree/context.d.ts +2 -2
  27. package/dist/tree/context.js +26 -10
  28. package/dist/tree/domain.d.ts +12 -0
  29. package/dist/tree/domain.js +10 -0
  30. package/dist/tree/events.js +21 -1
  31. package/dist/tree/group.js +3 -0
  32. package/dist/tree/index.d.ts +1 -1
  33. package/dist/tree/ports.d.ts +47 -7
  34. package/dist/tree/roster.d.ts +3 -11
  35. package/dist/tree/roster.js +4 -10
  36. package/dist/tree/send.js +46 -19
  37. package/dist/tree/spawn.js +57 -3
  38. package/dist/tree/standing.d.ts +17 -0
  39. package/dist/tree/standing.js +90 -0
  40. package/dist/tree/status.d.ts +3 -3
  41. package/dist/tree/status.js +4 -4
  42. package/dist/tree/stop.js +3 -0
  43. package/dist/tree/store.js +4 -2
  44. package/dist/tree/text.d.ts +37 -6
  45. package/dist/tree/text.js +74 -8
  46. package/dist/tree/tools.d.ts +0 -3
  47. package/dist/tree/tools.js +6 -19
  48. package/package.json +6 -6
package/dist/tree/send.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { envelope, isStatus, STATUSES } from "../kernel/protocol.js";
2
2
  import { groupStanding, holdReport, membershipFor, unholdForBlocked } from "./group.js";
3
+ import { syncStanding } from "./standing.js";
3
4
  import { childStore, groupStore } from "./store.js";
4
5
  import { reportBody } from "./text.js";
5
6
  import { noteSent } from "./wake.js";
@@ -8,37 +9,63 @@ import { noteSent } from "./wake.js";
8
9
  * to the one that started it, it is its report or its progress (`noteReport`); a group member's final report is held (`hold`).
9
10
  */
10
11
  export async function send(ports, from, input) {
11
- // Some models send null for an optional field they leave out.
12
- const status = input.status ?? undefined;
13
- if (status !== undefined && !isStatus(status))
14
- throw new Error(`status must be one of ${STATUSES.join(", ")}, not ${JSON.stringify(status)}.`);
12
+ const { status, dropped } = await statusFor(ports, from, input);
15
13
  // Noted first: the delivery may wake the parent, whose turn may end before a later note.
16
14
  const noted = await noteReport(ports, from, input.sessionID, status).catch(() => undefined);
17
15
  try {
18
16
  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.
18
+ if (noted)
19
+ await syncStanding(ports, input.sessionID);
19
20
  if (held)
20
21
  return { status, report: true, held };
21
- const text = envelope(from, reportBody(input.message, input.artifacts), status ? { status } : {});
22
- const description = `Message from ${from}`;
23
- const delivered = await ports.session.synthetic({
24
- sessionID: input.sessionID,
25
- text,
26
- description,
27
- metadata: { source: "courier", from, ...(status ? { status } : {}) },
28
- delivery: input.queue ? "queue" : "steer",
29
- });
30
- // Watched now, not when OpenCode's enqueued event is handled: a spawn in the same step must count the child it wakes.
31
- await noteSent(ports, { sessionID: input.sessionID, inboxID: delivered.id, text, description }).catch(() => undefined);
32
- // A blocked report, once delivered, is the member's latest word: an earlier report held for it would be stale.
33
- if (noted?.member && status === "blocked")
34
- await unholdForBlocked(groupStore(ports.storage), noted.member).catch(() => undefined);
35
- return { messageID: delivered.id, ...(status ? { status } : {}), ...(noted ? { report: noted.report } : {}) };
22
+ const delivered = await deliver(ports, from, input, status, noted);
23
+ return { ...delivered, ...(noted ? { report: noted.report } : {}), ...(dropped ? { dropped } : {}) };
36
24
  }
37
25
  catch (error) {
38
26
  await noted?.undo().catch(() => undefined);
27
+ // The standing as before the note, once the note is taken back.
28
+ if (noted)
29
+ await syncStanding(ports, input.sessionID);
39
30
  throw error;
40
31
  }
41
32
  }
33
+ /** Delivers the message, watches it, and takes a held report back after a blocked one; the message id and status delivered. */
34
+ async function deliver(ports, from, input, status, noted) {
35
+ const text = envelope(from, reportBody(input.message, input.artifacts), status ? { status } : {});
36
+ const description = `Message from ${from}`;
37
+ const delivered = await ports.session.synthetic({
38
+ sessionID: input.sessionID,
39
+ text,
40
+ description,
41
+ metadata: { source: "courier", from, ...(status ? { status } : {}) },
42
+ delivery: input.queue ? "queue" : "steer",
43
+ });
44
+ // Watched now, not when OpenCode's enqueued event is handled: a spawn in the same step must count the child it wakes.
45
+ 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
+ if (noted?.member && status === "blocked")
48
+ await unholdForBlocked(groupStore(ports.storage), noted.member).catch(() => undefined);
49
+ return { messageID: delivered.id, ...(status ? { status } : {}) };
50
+ }
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
+ */
55
+ async function statusFor(ports, from, input) {
56
+ // Some models send null for an optional field they leave out.
57
+ const given = input.status ?? undefined;
58
+ if (given === undefined)
59
+ return {};
60
+ if (!isStatus(given))
61
+ throw new Error(`status must be one of ${STATUSES.join(", ")}, not ${JSON.stringify(given)}.`);
62
+ return (await isBelow(ports, from, input.sessionID)) ? { dropped: given } : { status: given };
63
+ }
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
+ async function isBelow(ports, from, to) {
66
+ const above = await childStore(ports.storage).indexedAncestors(to).catch(() => []);
67
+ return above.includes(from);
68
+ }
42
69
  /**
43
70
  * Notes `from`'s message to `to` when `to` started it: as its report when it carries a status, or when an earlier
44
71
  * release briefed `from`, else as progress. Returns which, when, how to take the note back, and `from`'s group membership.
@@ -3,8 +3,9 @@ import { isGroupName, modelName } from "./domain.js";
3
3
  import { joinGroup, leaveGroup } from "./group.js";
4
4
  import { admit } from "./limits.js";
5
5
  import { chooseModel } from "./model.js";
6
+ import { putEntries, syncStanding } from "./standing.js";
6
7
  import { childStore, groupStore } from "./store.js";
7
- import { childBrief, childBranch, nativeIsolateRefusal } from "./text.js";
8
+ import { childBrief, childBranch, nativeIsolateRefusal, ROLE_KEY, rolePart } from "./text.js";
8
9
  function titleOf(task) {
9
10
  const line = task.trim().split("\n")[0] ?? "";
10
11
  return line.length > 60 ? `${line.slice(0, 57)}...` : line;
@@ -52,8 +53,14 @@ async function start(ports, parentID, input, admitted, group) {
52
53
  ...(model ? { model } : {}),
53
54
  };
54
55
  const { rosterError, groupError } = await enrol(ports, entry, group);
55
- ports.roles.delete(parentID);
56
- await handOver(ports, entry, admitted.depth, input.task, !rosterError, !rosterError && !groupError);
56
+ await nameBoth(ports, parentID, entry.sessionID, admitted.depth);
57
+ try {
58
+ await handOver(ports, entry, admitted.depth, input.task, !rosterError, !rosterError && !groupError);
59
+ }
60
+ finally {
61
+ // The parent's standing with the child on it, prompted or failed, announced at its next step.
62
+ await syncStanding(ports, parentID);
63
+ }
57
64
  return {
58
65
  sessionID: entry.sessionID,
59
66
  directory: entry.directory,
@@ -170,3 +177,50 @@ async function join(ports, entry, group) {
170
177
  return describeFailure("group", error).message;
171
178
  }
172
179
  }
180
+ /**
181
+ * The child's role and the other modules' entries before its prompt, so its first request carries them, and a root's own at
182
+ * its first spawn, remembered for the `context` hook; a parent that is no root is forgotten there, as its depth may have changed.
183
+ */
184
+ async function nameBoth(ports, parentID, childID, depth) {
185
+ const extra = await extraEntries(ports);
186
+ await nameRole(ports, childID, depth);
187
+ if (depth < ports.limits.maxDepth)
188
+ await putEntries(ports, childID, extra);
189
+ const rootNamed = ports.roles.get(parentID)?.entry === true;
190
+ const named = depth === 1 && (rootNamed || (await nameRole(ports, parentID, 0)));
191
+ if (named && !rootNamed)
192
+ await putEntries(ports, parentID, extra);
193
+ if (named)
194
+ ports.roles.set(parentID, { depth: 0, entry: true });
195
+ else
196
+ ports.roles.delete(parentID);
197
+ }
198
+ /** The entries the other modules give a spawned session, the memory's lessons; none when they cannot be read, logged. */
199
+ async function extraEntries(ports) {
200
+ if (!ports.entries || !ports.session.instructions)
201
+ return [];
202
+ try {
203
+ return await ports.entries();
204
+ }
205
+ catch (error) {
206
+ ports.log(`courier_spawn: the entries of the other modules could not be read, so the session starts without them: ${String(error)}`);
207
+ return [];
208
+ }
209
+ }
210
+ /**
211
+ * Writes a session's role part to its `role` entry: a child's system prompt carries it from its first request, a root's
212
+ * transcript as an update. False on a host without entries, or when the put failed, logged: the `context` hook adds the part.
213
+ */
214
+ async function nameRole(ports, sessionID, depth) {
215
+ const entries = ports.session.instructions?.entry;
216
+ if (!entries)
217
+ return false;
218
+ try {
219
+ await entries.put({ sessionID, key: ROLE_KEY, value: rolePart(depth, ports.limits) });
220
+ return true;
221
+ }
222
+ catch (error) {
223
+ ports.log(`courier_spawn: the role entry of ${sessionID} could not be written, so its role goes in a system part: ${String(error)}`);
224
+ return false;
225
+ }
226
+ }
@@ -0,0 +1,17 @@
1
+ import type { Entry, ReportNote, RosterStorage, StandingPorts } from "./ports.js";
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
+ export declare function stateOf(notes: {
5
+ readonly [K in "prompt" | "settled" | "progress" | "told" | "stopped"]: ReportNote;
6
+ }): ChildState;
7
+ /** The rows of a parent's standing entry: its unexpired children, oldest first, each with its state and group standing. */
8
+ export declare function standingRows(storage: RosterStorage, parentID: string, now: number): Promise<StandingRow[]>;
9
+ /** What `syncStanding` did to the parent's entry. */
10
+ 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
+ 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
+ export declare function putEntries(ports: StandingPorts, sessionID: string, given: ReadonlyArray<Entry>): Promise<void>;
@@ -0,0 +1,90 @@
1
+ import { existsSync } from "node:fs";
2
+ import { isStatus } from "../kernel/protocol.js";
3
+ import { isNotFound } from "../platform/json.js";
4
+ import { isExpired, standingOf } from "./domain.js";
5
+ import { childStore, groupStore } from "./store.js";
6
+ import { STANDING_KEY, standingEntry } from "./text.js";
7
+ /** Whether `at` is known and no earlier than the prompt at `prompt`. */
8
+ 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
+ export function stateOf(notes) {
11
+ const prompt = notes.prompt.at;
12
+ if (notes.stopped.at !== undefined && (prompt === undefined || notes.stopped.at >= prompt))
13
+ return { kind: "stopped" };
14
+ if (prompt === undefined)
15
+ return { kind: "started" };
16
+ const progressed = since(notes.progress.at, prompt) ? { progressed: true } : {};
17
+ if (!since(notes.settled.at, prompt))
18
+ return since(notes.told.at, prompt) ? { kind: "ended", ...progressed } : { kind: "working", ...progressed };
19
+ const { by, status } = notes.settled;
20
+ if (by === "failed" || by === "interrupted")
21
+ return { kind: by };
22
+ return { kind: "reported", status: isStatus(status) ? status : "done", ...progressed };
23
+ }
24
+ /** The rows of a parent's standing entry: its unexpired children, oldest first, each with its state and group standing. */
25
+ export async function standingRows(storage, parentID, now) {
26
+ 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
+ const [entries, members] = await Promise.all([children.children(parentID), groupStore(storage).ofParent(parentID).catch(() => undefined)]);
29
+ const current = entries.filter((entry) => !isExpired(entry, now, existsSync));
30
+ return Promise.all(current.map(async (entry) => {
31
+ const notes = await children.notes(entry.sessionID, ["prompt", "settled", "progress", "told", "stopped"]);
32
+ const member = members?.find((one) => one.group === entry.group && one.sessionID === entry.sessionID);
33
+ const standing = members ? standingOf(member) : "unknown";
34
+ return {
35
+ sessionID: entry.sessionID,
36
+ title: entry.title,
37
+ state: stateOf(notes),
38
+ ...(entry.group ? { group: { name: entry.group, standing } } : {}),
39
+ };
40
+ }));
41
+ }
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
+ 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
+ export function syncStanding(ports, parentID) {
49
+ const entries = ports.session?.instructions?.entry;
50
+ if (!entries)
51
+ return Promise.resolve("skipped");
52
+ const before = inTurn.get(parentID) ?? Promise.resolve();
53
+ const run = before.then(() => sync(ports, parentID, entries));
54
+ const done = run.finally(() => {
55
+ if (inTurn.get(parentID) === done)
56
+ inTurn.delete(parentID);
57
+ });
58
+ inTurn.set(parentID, done);
59
+ return run;
60
+ }
61
+ async function sync(ports, parentID, entries) {
62
+ try {
63
+ const rows = await standingRows(ports.storage, parentID, ports.now());
64
+ const stored = (await entries.list({ sessionID: parentID })).find((entry) => entry.key === STANDING_KEY);
65
+ if (!rows.length) {
66
+ if (!stored)
67
+ return "absent";
68
+ await entries.remove({ sessionID: parentID, key: STANDING_KEY });
69
+ return "removed";
70
+ }
71
+ const value = standingEntry(rows);
72
+ if (stored?.value === value)
73
+ return "unchanged";
74
+ await entries.put({ sessionID: parentID, key: STANDING_KEY, value });
75
+ return "put";
76
+ }
77
+ catch (error) {
78
+ // A parent OpenCode no longer knows has no entry to keep: a child deleted after it is a normal path.
79
+ if (!isNotFound(error))
80
+ ports.log?.(`courier: the standing entry of ${parentID} could not be written, so it may be behind courier_children: ${String(error)}`);
81
+ return "skipped";
82
+ }
83
+ }
84
+ /** Writes `given` entries to a session, each put logged on failure and never thrown: the session gets its task all the same. */
85
+ export async function putEntries(ports, sessionID, given) {
86
+ const entries = ports.session?.instructions?.entry;
87
+ if (!entries)
88
+ return;
89
+ await Promise.all(given.map(({ key, value }) => entries.put({ sessionID, key, value }).catch((error) => ports.log?.(`courier_spawn: the ${key} entry of ${sessionID} could not be written: ${String(error)}`))));
90
+ }
@@ -7,7 +7,7 @@ export interface StatusInput {
7
7
  export interface ChildrenInput {
8
8
  readonly sessionID?: string;
9
9
  }
10
- /** A one-off look at a session, for check-ins; not meant to be called in a loop. */
10
+ /** One session's state and last reply, as courier_children shows each child. */
11
11
  export declare function status(ports: CourierPorts, input: StatusInput): Promise<Partial<{
12
12
  sessionID: string;
13
13
  title: string | undefined;
@@ -22,7 +22,7 @@ export declare function status(ports: CourierPorts, input: StatusInput): Promise
22
22
  /** The text of a session's last reply, from its context's messages, or undefined when it has none. */
23
23
  export declare function lastReply(messages: Awaited<ReturnType<Context["session"]["context"]>>): string | undefined;
24
24
  /**
25
- * The sessions a parent started, each with what courier_status reports, or the error it gave; a child in a
25
+ * The sessions a parent started, each with its state and last reply, or the error it gave; a child in a
26
26
  * group with the group's name and its standing there: `held`, `out`, how it left, `released` or `unknown`.
27
27
  */
28
28
  export declare function listChildren(ports: CourierPorts, parentID: string): Promise<({
@@ -84,7 +84,7 @@ export declare function listChildren(ports: CourierPorts, parentID: string): Pro
84
84
  sessionID: string;
85
85
  title: string;
86
86
  })[]>;
87
- /** One roster entry as courier_children shows it: what courier_status reports with its directory and group standing, or the error it gave. */
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
88
  export declare function described(ports: CourierPorts, entry: RosterEntry): Promise<{
89
89
  group: {
90
90
  name: string;
@@ -2,7 +2,7 @@ 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
- /** A one-off look at a session, for check-ins; not meant to be called in a loop. */
5
+ /** One session's state and last reply, as courier_children shows each child. */
6
6
  export async function status(ports, input) {
7
7
  const [info, messages, pending] = await Promise.all([
8
8
  ports.session.get({ sessionID: input.sessionID }),
@@ -30,14 +30,14 @@ export function lastReply(messages) {
30
30
  : undefined;
31
31
  }
32
32
  /**
33
- * The sessions a parent started, each with what courier_status reports, or the error it gave; a child in a
33
+ * The sessions a parent started, each with its state and last reply, or the error it gave; a child in a
34
34
  * group with the group's name and its standing there: `held`, `out`, how it left, `released` or `unknown`.
35
35
  */
36
36
  export async function listChildren(ports, parentID) {
37
37
  const entries = await current(ports.storage, parentID, ports.now());
38
38
  return Promise.all(entries.map((entry) => described(ports, entry)));
39
39
  }
40
- /** One roster entry as courier_children shows it: what courier_status reports with its directory and group standing, or the error it gave. */
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
41
  export async function described(ports, entry) {
42
42
  const group = entry.group ? { group: { name: entry.group, report: await standingIn(ports, entry, entry.group) } } : {};
43
43
  const roster = { directory: entry.directory, ...startedAs(entry), created: entry.createdAt, ...group };
@@ -49,7 +49,7 @@ export async function described(ports, entry) {
49
49
  sessionID: entry.sessionID,
50
50
  title: entry.title,
51
51
  ...roster,
52
- error: describeFailure("courier_status", error).message,
52
+ error: describeFailure("reading the session", error).message,
53
53
  };
54
54
  }
55
55
  }
package/dist/tree/stop.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { isNotFound } from "../platform/json.js";
2
2
  import { describeFailure, envelope } from "../kernel/protocol.js";
3
3
  import { cleanup } from "./cleanup.js";
4
+ import { syncStanding } from "./standing.js";
4
5
  import { childStore } from "./store.js";
5
6
  import { subtree } from "./subtree.js";
6
7
  import { stoppedNotice } from "./text.js";
@@ -37,6 +38,8 @@ export async function stop(ports, callerID, input) {
37
38
  return round(meanwhile, left - 1);
38
39
  };
39
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)));
40
43
  const told = target.parentID !== callerID && results.find((one) => one.sessionID === sessionID)?.outcome === "interrupted"
41
44
  ? await tell(ports, callerID, target)
42
45
  : undefined;
@@ -65,10 +65,11 @@ const noteOf = (value) => ({ at: timeOf(value), by: obj(value).by, status: obj(v
65
65
  export function childStore(storage) {
66
66
  const entry = async (parentID, sessionID) => (await storage.get(rosterKey(parentID, sessionID)));
67
67
  const all = () => scanAll(storage, PREFIX);
68
- const indexedParent = async (sessionID) => {
68
+ const indexedAncestors = async (sessionID) => {
69
69
  const indexed = await storage.get(reverseKey(sessionID));
70
- return isReverse(indexed) ? indexed.ancestors[0] : undefined;
70
+ return isReverse(indexed) ? indexed.ancestors : [];
71
71
  };
72
+ const indexedParent = async (sessionID) => (await indexedAncestors(sessionID))[0];
72
73
  const walkIndex = async (sessionID, first) => {
73
74
  let indexed = first;
74
75
  const chain = [];
@@ -143,6 +144,7 @@ export function childStore(storage) {
143
144
  return found === undefined ? [] : [found];
144
145
  },
145
146
  indexedParent,
147
+ indexedAncestors,
146
148
  async indexedEntry(sessionID) {
147
149
  const parentID = await indexedParent(sessionID);
148
150
  return parentID === undefined ? undefined : entry(parentID, sessionID);
@@ -1,16 +1,49 @@
1
1
  import { type ModelRef } from "./domain.js";
2
- import type { Artifacts, CleanupResult, ExecutionError, HeldReport, LeftMember, Limits, Sent } from "./ports.js";
2
+ import type { Artifacts, CleanupResult, ExecutionError, HeldReport, LeftMember, Limits, Sent, Standing } from "./ports.js";
3
3
  /** The branch an isolated child commits its work on, and names in the `branch` of its report. */
4
4
  export declare const childBranch: (sessionID: string) => string;
5
5
  /** What a spawned session at `depth` is told; `depth` 1 is a child of a session nobody spawned. `branch` is for an isolated one. */
6
6
  export declare function childBrief(parentID: string, task: string, depth: number, limits: Limits, branch?: string): string;
7
- /** How every part the context hook adds begins, so a second copy of the plugin does not add its own. */
7
+ /** How every role part begins, in the role entry and in the part the context hook adds without one, so no second copy is added. */
8
8
  export declare const ROLE_PREFIX = "opencode-courier role:";
9
+ /** The key of the session instruction entry carrying a spawned session's role part, `<context key="role">` in its system prompt. */
10
+ export declare const ROLE_KEY = "role";
9
11
  /**
10
12
  * The system part naming a session's place in its tree: `depth` 0 for a session nobody spawned that
11
13
  * has started one, `null` for one that has started none, which is pointed to the skill. The same on every request.
12
14
  */
13
15
  export declare function rolePart(depth: number | null, limits: Limits): string;
16
+ /** The key of the session instruction entry on a parent listing the sessions it started, `<context key="standing">`. */
17
+ export declare const STANDING_KEY = "standing";
18
+ /** How every standing entry begins. */
19
+ export declare const STANDING_PREFIX = "opencode-courier standing:";
20
+ /** How many children the entry lists; older ones past that are counted. */
21
+ export declare const STANDING_MAX = 10;
22
+ /** What a child is doing, as its parent's standing entry says it; `reported` carries the status. */
23
+ export type ChildState = {
24
+ readonly kind: "started" | "working" | "ended" | "failed" | "interrupted" | "stopped";
25
+ readonly progressed?: boolean;
26
+ } | {
27
+ readonly kind: "reported";
28
+ readonly status: string;
29
+ readonly progressed?: boolean;
30
+ };
31
+ /** One child as the standing entry lists it. */
32
+ export interface StandingRow {
33
+ readonly sessionID: string;
34
+ readonly title: string;
35
+ readonly state: ChildState;
36
+ /** Its join group and its standing there, `unknown` when that could not be read. */
37
+ readonly group?: {
38
+ readonly name: string;
39
+ readonly standing: Standing | "unknown";
40
+ };
41
+ }
42
+ /**
43
+ * The standing entry: one line per session the parent started, oldest first, with its state and its place in its join group,
44
+ * in the same words for the same state, so an unchanged standing is not put again.
45
+ */
46
+ export declare function standingEntry(rows: ReadonlyArray<StandingRow>): string;
14
47
  export declare const depthRefusal: (depth: number, maxDepth: number) => string;
15
48
  export declare const childrenRefusal: (running: number, maxChildren: number) => string;
16
49
  export declare const totalRefusal: (running: number, maxTotal: number) => string;
@@ -38,10 +71,8 @@ export declare function spawnText(child: {
38
71
  /** The model it runs on, when that is not the caller's own. */
39
72
  readonly model?: string;
40
73
  }): string;
41
- /** courier_send's result: for a message to the session that started the sender, whether it was its report. */
42
- export declare function sendText(sessionID: string, sent?: Pick<Sent, "status" | "report" | "held">): string;
43
- /** courier_status's result: the session's state, as JSON. */
44
- export declare const statusText: (status: unknown) => string;
74
+ /** courier_send's result: for a message to the session that started the sender, whether it was its report; below it, the status dropped. */
75
+ export declare function sendText(sessionID: string, sent?: Pick<Sent, "status" | "report" | "held" | "dropped">): string;
45
76
  export declare const childrenText: (listed: ReadonlyArray<unknown>) => string;
46
77
  export declare function cleanupText(result: CleanupResult): string;
47
78
  /** courier_tree's result: the sessions under a session, drawn depth first, as JSON. */
package/dist/tree/text.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** The tree's model-facing texts: the child brief, the role part, refusals, reports, notices and tool results. */
2
2
  import { obj, str } from "../platform/json.js";
3
- import { clipText, defuse, END_TURN, ROOT_SPLIT_LINE, SKILL_ID, SPLIT_LINE, STATUSES } from "../kernel/protocol.js";
3
+ import { clipText, defuse, defuseBlock, END_TURN, ROOT_SPLIT_LINE, SKILL_ID, SPLIT_LINE, STATUSES } from "../kernel/protocol.js";
4
4
  import { retryDelay } from "./domain.js";
5
5
  // The envelope and the child brief.
6
6
  /** How a text names the statuses a report may carry. */
@@ -20,8 +20,10 @@ export function childBrief(parentID, task, depth, limits, branch) {
20
20
  "A report with a status wakes the parent and is the only way it hears from you, so do not end without sending one. " +
21
21
  "A message without a status is progress, not your report: if you end your turn without a report, the parent is told so.",
22
22
  "If you find something the other parts of the job need (an assumption of the split that is wrong, a shared file or API " +
23
- "that behaves otherwise, something the person told you), send it to the parent at once with courier_send without a " +
24
- "status, then carry on: it passes it on to the sessions it affects. Progress alone is not worth a message.",
23
+ "that behaves otherwise), send it to the parent at once with courier_send without a status, then carry on: it passes " +
24
+ "it on to the sessions it affects. Progress alone is not worth a message.",
25
+ "A message the person writes in your session, outside a <courier> envelope, is for the whole job: before you act on it, " +
26
+ "send it to the parent at once with courier_send without a status, quoting it as the person's, then carry on.",
25
27
  "If you need the person to decide something, use your question tool; it reaches them through the session that started you.",
26
28
  "",
27
29
  ...(branch ? [...isolationRule(branch, depth < limits.maxDepth), ""] : []),
@@ -56,13 +58,15 @@ function splitRule(parentID, depth, limits) {
56
58
  "One session alone, with nothing of the task done before it by you or by 2 or more parts, and fewer than 2 parts after it, is the whole task.",
57
59
  `- Say which in one line of your reply: ${SPLIT_LINE}.`,
58
60
  "- If you split, you orchestrate: start the parts that run at once in one group, with the same group name for each, " +
59
- "then end your turn while they work. Do not poll with courier_status, courier_children, courier_tree or sleep: the group's " +
61
+ "then end your turn while they work. Do not poll with courier_children, courier_tree or sleep: the group's " +
60
62
  "reports wake you. Check and integrate each part yourself when it reports (never hand that checking to another " +
61
63
  `session), then send ${parentID} one combined report.`,
62
64
  ];
63
65
  }
64
- /** How every part the context hook adds begins, so a second copy of the plugin does not add its own. */
66
+ /** How every role part begins, in the role entry and in the part the context hook adds without one, so no second copy is added. */
65
67
  export const ROLE_PREFIX = "opencode-courier role:";
68
+ /** The key of the session instruction entry carrying a spawned session's role part, `<context key="role">` in its system prompt. */
69
+ export const ROLE_KEY = "role";
66
70
  /**
67
71
  * The system part naming a session's place in its tree: `depth` 0 for a session nobody spawned that
68
72
  * has started one, `null` for one that has started none, which is pointed to the skill. The same on every request.
@@ -85,6 +89,67 @@ export function rolePart(depth, limits) {
85
89
  return (`${ROLE_PREFIX} leaf, at depth ${depth}, the deepest the session tree goes. You cannot start sessions: do your ` +
86
90
  "task yourself and report with courier_send to the session that started you.");
87
91
  }
92
+ // The standing entry: what a parent's system prompt says of the sessions it started.
93
+ /** The key of the session instruction entry on a parent listing the sessions it started, `<context key="standing">`. */
94
+ export const STANDING_KEY = "standing";
95
+ /** How every standing entry begins. */
96
+ export const STANDING_PREFIX = "opencode-courier standing:";
97
+ /** How many children the entry lists; older ones past that are counted. */
98
+ export const STANDING_MAX = 10;
99
+ /** The most characters of a child's title the entry shows. */
100
+ const TITLE_MAX = 40;
101
+ function stateWords(state) {
102
+ const progressed = state.progressed ? ", sent progress" : "";
103
+ switch (state.kind) {
104
+ case "started":
105
+ return "started";
106
+ case "working":
107
+ return `working${progressed}`;
108
+ case "ended":
109
+ return `ended its turn without a report${progressed}`;
110
+ case "failed":
111
+ return "its turn failed";
112
+ case "interrupted":
113
+ return "its turn was interrupted";
114
+ case "stopped":
115
+ return "stopped";
116
+ case "reported":
117
+ return `reported ${state.status}`;
118
+ }
119
+ }
120
+ function groupWords(name, standing) {
121
+ const group = `group "${name}"`;
122
+ switch (standing) {
123
+ case "held":
124
+ return `its report is held with ${group}`;
125
+ case "out":
126
+ return `${group} waits for it`;
127
+ case "released":
128
+ return `${group} was delivered`;
129
+ case "unknown":
130
+ return `in ${group}`;
131
+ default:
132
+ return `out of ${group} (${LEFT_HOW[standing]})`;
133
+ }
134
+ }
135
+ /**
136
+ * The standing entry: one line per session the parent started, oldest first, with its state and its place in its join group,
137
+ * in the same words for the same state, so an unchanged standing is not put again.
138
+ */
139
+ export function standingEntry(rows) {
140
+ const shown = rows.slice(-STANDING_MAX);
141
+ const older = rows.length - shown.length;
142
+ return [
143
+ `${STANDING_PREFIX} the sessions you started with courier_spawn, each with its state, kept current as it changes, so you need ` +
144
+ "not look with courier_children or courier_tree; a report, a group's reports or a notice still reach you as a message.",
145
+ ...shown.map((row) => {
146
+ const title = defuseBlock(row.title.replace(/\s+/g, " ").trim());
147
+ const group = row.group ? `; ${groupWords(row.group.name, row.group.standing)}` : "";
148
+ return `- ${row.sessionID} "${clipText(title, TITLE_MAX)}": ${stateWords(row.state)}${group}`;
149
+ }),
150
+ ...(older ? [`${count(older, "older session")} not listed; courier_children lists them.`] : []),
151
+ ].join("\n");
152
+ }
88
153
  // courier_spawn's refusals, one per limit.
89
154
  export const depthRefusal = (depth, maxDepth) => `Not started: this session is at depth ${depth} of its session tree, and the tree goes at most ${maxDepth} deep ` +
90
155
  "(maxDepth), so it cannot start sessions. Do the task yourself, and report with courier_send.";
@@ -166,8 +231,11 @@ export function spawnText(child) {
166
231
  return (`Started session ${child.sessionID} in ${child.directory}${on}. ${reports} Once you have started every session you need, ` +
167
232
  `end your turn: reply without calling more tools. That does not drop the task; you carry on with it when the reports arrive.${warning}`);
168
233
  }
169
- /** courier_send's result: for a message to the session that started the sender, whether it was its report. */
234
+ /** courier_send's result: for a message to the session that started the sender, whether it was its report; below it, the status dropped. */
170
235
  export function sendText(sessionID, sent = {}) {
236
+ if (sent.dropped)
237
+ return (`Delivered to ${sessionID}, without the status ${sent.dropped}: a status belongs on a report to the session that ` +
238
+ "started you, and means nothing on a message to a session you started.");
171
239
  const status = sent.status ? `, status ${sent.status}` : "";
172
240
  if (sent.held) {
173
241
  const { group, reported, members } = sent.held;
@@ -185,8 +253,6 @@ export function sendText(sessionID, sent = {}) {
185
253
  return `Delivered to ${sessionID}. Without a status it is progress, not your report: when you finish, send one with status ${STATUS_LIST}.`;
186
254
  return `Delivered to ${sessionID}.`;
187
255
  }
188
- /** courier_status's result: the session's state, as JSON. */
189
- export const statusText = (status) => JSON.stringify(status, null, 2);
190
256
  export const childrenText = (listed) => listed.length ? JSON.stringify(listed, null, 2) : "No sessions started with courier_spawn.";
191
257
  export function cleanupText(result) {
192
258
  if (result.outcome === "kept")
@@ -30,9 +30,6 @@ export declare const SendInput: Schema.Struct<{
30
30
  }>>;
31
31
  readonly queue: Schema.optional<Schema.Boolean>;
32
32
  }>;
33
- export declare const StatusInput: Schema.Struct<{
34
- readonly sessionID: Schema.String;
35
- }>;
36
33
  export declare const ChildrenInput: Schema.Struct<{
37
34
  readonly sessionID: Schema.optional<Schema.String>;
38
35
  }>;