opencode-courier 0.11.1 → 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 (59) hide show
  1. package/README.md +19 -14
  2. package/dist/index.js +19 -6
  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/memory/tools.js +2 -1
  15. package/dist/person/events.js +4 -1
  16. package/dist/person/permission.d.ts +1 -1
  17. package/dist/person/permission.js +1 -1
  18. package/dist/person/question/answer.d.ts +1 -1
  19. package/dist/person/question/answer.js +1 -1
  20. package/dist/person/question/lifecycle.d.ts +1 -1
  21. package/dist/person/question/lifecycle.js +1 -1
  22. package/dist/person/question/relay.js +1 -1
  23. package/dist/platform/events.d.ts +10 -2
  24. package/dist/platform/events.js +38 -8
  25. package/dist/skill.d.ts +1 -1
  26. package/dist/skill.js +97 -45
  27. package/dist/tree/cleanup.js +2 -0
  28. package/dist/tree/context.d.ts +2 -2
  29. package/dist/tree/context.js +26 -10
  30. package/dist/tree/domain.d.ts +32 -2
  31. package/dist/tree/domain.js +28 -2
  32. package/dist/tree/events.js +21 -1
  33. package/dist/tree/group.js +3 -0
  34. package/dist/tree/index.d.ts +1 -1
  35. package/dist/tree/model.d.ts +18 -0
  36. package/dist/tree/model.js +65 -0
  37. package/dist/tree/panel.js +3 -2
  38. package/dist/tree/ports.d.ts +59 -13
  39. package/dist/tree/roster.d.ts +3 -11
  40. package/dist/tree/roster.js +4 -10
  41. package/dist/tree/rpc-handlers.d.ts +6 -0
  42. package/dist/tree/rpc.d.ts +16 -0
  43. package/dist/tree/rpc.js +4 -0
  44. package/dist/tree/send.js +46 -19
  45. package/dist/tree/spawn.d.ts +8 -0
  46. package/dist/tree/spawn.js +74 -21
  47. package/dist/tree/standing.d.ts +17 -0
  48. package/dist/tree/standing.js +90 -0
  49. package/dist/tree/status.d.ts +35 -19
  50. package/dist/tree/status.js +6 -6
  51. package/dist/tree/stop.js +3 -0
  52. package/dist/tree/store.js +4 -2
  53. package/dist/tree/subtree.d.ts +16 -8
  54. package/dist/tree/text.d.ts +48 -6
  55. package/dist/tree/text.js +113 -8
  56. package/dist/tree/tools.d.ts +3 -3
  57. package/dist/tree/tools.js +24 -20
  58. package/dist/tui.js +8 -0
  59. package/package.json +6 -6
@@ -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<({
@@ -30,9 +30,11 @@ export declare function listChildren(ports: CourierPorts, parentID: string): Pro
30
30
  name: string;
31
31
  report: "failed" | "interrupted" | "out" | "unknown" | "deleted" | "dropped" | "held" | "released";
32
32
  };
33
- directory: string;
34
- isolated: boolean;
35
33
  created: number;
34
+ model?: string | undefined;
35
+ native?: true | undefined;
36
+ isolated: boolean;
37
+ directory: string;
36
38
  sessionID?: string | undefined;
37
39
  title?: string | undefined;
38
40
  parentID?: string | undefined;
@@ -44,9 +46,11 @@ export declare function listChildren(ports: CourierPorts, parentID: string): Pro
44
46
  pending?: readonly import("./ports.js").Pending[] | undefined;
45
47
  } | {
46
48
  group?: undefined;
47
- directory: string;
48
- isolated: boolean;
49
49
  created: number;
50
+ model?: string | undefined;
51
+ native?: true | undefined;
52
+ isolated: boolean;
53
+ directory: string;
50
54
  sessionID?: string | undefined;
51
55
  title?: string | undefined;
52
56
  parentID?: string | undefined;
@@ -62,29 +66,35 @@ export declare function listChildren(ports: CourierPorts, parentID: string): Pro
62
66
  name: string;
63
67
  report: "failed" | "interrupted" | "out" | "unknown" | "deleted" | "dropped" | "held" | "released";
64
68
  };
65
- directory: string;
66
- isolated: boolean;
67
69
  created: number;
70
+ model?: string | undefined;
71
+ native?: true | undefined;
72
+ isolated: boolean;
73
+ directory: string;
68
74
  sessionID: string;
69
75
  title: string;
70
76
  } | {
71
77
  error: string;
72
78
  group?: undefined;
73
- directory: string;
74
- isolated: boolean;
75
79
  created: number;
80
+ model?: string | undefined;
81
+ native?: true | undefined;
82
+ isolated: boolean;
83
+ directory: string;
76
84
  sessionID: string;
77
85
  title: string;
78
86
  })[]>;
79
- /** 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. */
80
88
  export declare function described(ports: CourierPorts, entry: RosterEntry): Promise<{
81
89
  group: {
82
90
  name: string;
83
91
  report: "failed" | "interrupted" | "out" | "unknown" | "deleted" | "dropped" | "held" | "released";
84
92
  };
85
- directory: string;
86
- isolated: boolean;
87
93
  created: number;
94
+ model?: string | undefined;
95
+ native?: true | undefined;
96
+ isolated: boolean;
97
+ directory: string;
88
98
  sessionID?: string | undefined;
89
99
  title?: string | undefined;
90
100
  parentID?: string | undefined;
@@ -96,9 +106,11 @@ export declare function described(ports: CourierPorts, entry: RosterEntry): Prom
96
106
  pending?: readonly import("./ports.js").Pending[] | undefined;
97
107
  } | {
98
108
  group?: undefined;
99
- directory: string;
100
- isolated: boolean;
101
109
  created: number;
110
+ model?: string | undefined;
111
+ native?: true | undefined;
112
+ isolated: boolean;
113
+ directory: string;
102
114
  sessionID?: string | undefined;
103
115
  title?: string | undefined;
104
116
  parentID?: string | undefined;
@@ -114,17 +126,21 @@ export declare function described(ports: CourierPorts, entry: RosterEntry): Prom
114
126
  name: string;
115
127
  report: "failed" | "interrupted" | "out" | "unknown" | "deleted" | "dropped" | "held" | "released";
116
128
  };
117
- directory: string;
118
- isolated: boolean;
119
129
  created: number;
130
+ model?: string | undefined;
131
+ native?: true | undefined;
132
+ isolated: boolean;
133
+ directory: string;
120
134
  sessionID: string;
121
135
  title: string;
122
136
  } | {
123
137
  error: string;
124
138
  group?: undefined;
125
- directory: string;
126
- isolated: boolean;
127
139
  created: number;
140
+ model?: string | undefined;
141
+ native?: true | undefined;
142
+ isolated: boolean;
143
+ directory: string;
128
144
  sessionID: string;
129
145
  title: string;
130
146
  }>;
@@ -1,8 +1,8 @@
1
1
  import { describeFailure } from "../kernel/protocol.js";
2
- import { standingOf } from "./domain.js";
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,17 +30,17 @@ 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
- const roster = { directory: entry.directory, isolated: entry.isolated, created: entry.createdAt, ...group };
43
+ const roster = { directory: entry.directory, ...startedAs(entry), created: entry.createdAt, ...group };
44
44
  try {
45
45
  return { ...(await status(ports, { sessionID: entry.sessionID })), ...roster };
46
46
  }
@@ -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);
@@ -65,9 +65,11 @@ export declare function tree(ports: CourierPorts, rootID: string): Promise<{
65
65
  name: string;
66
66
  report: "failed" | "interrupted" | "out" | "unknown" | "deleted" | "dropped" | "held" | "released";
67
67
  };
68
- directory: string;
69
- isolated: boolean;
70
68
  created: number;
69
+ model?: string | undefined;
70
+ native?: true | undefined;
71
+ isolated: boolean;
72
+ directory: string;
71
73
  sessionID?: string | undefined;
72
74
  title?: string | undefined;
73
75
  outcome?: "failed" | "interrupted" | "succeeded" | undefined;
@@ -86,9 +88,11 @@ export declare function tree(ports: CourierPorts, rootID: string): Promise<{
86
88
  parentID: string;
87
89
  depth: number;
88
90
  group?: undefined;
89
- directory: string;
90
- isolated: boolean;
91
91
  created: number;
92
+ model?: string | undefined;
93
+ native?: true | undefined;
94
+ isolated: boolean;
95
+ directory: string;
92
96
  sessionID?: string | undefined;
93
97
  title?: string | undefined;
94
98
  outcome?: "failed" | "interrupted" | "succeeded" | undefined;
@@ -111,9 +115,11 @@ export declare function tree(ports: CourierPorts, rootID: string): Promise<{
111
115
  name: string;
112
116
  report: "failed" | "interrupted" | "out" | "unknown" | "deleted" | "dropped" | "held" | "released";
113
117
  };
114
- directory: string;
115
- isolated: boolean;
116
118
  created: number;
119
+ model?: string | undefined;
120
+ native?: true | undefined;
121
+ isolated: boolean;
122
+ directory: string;
117
123
  sessionID: string;
118
124
  title: string;
119
125
  } | {
@@ -127,9 +133,11 @@ export declare function tree(ports: CourierPorts, rootID: string): Promise<{
127
133
  depth: number;
128
134
  error: string;
129
135
  group?: undefined;
130
- directory: string;
131
- isolated: boolean;
132
136
  created: number;
137
+ model?: string | undefined;
138
+ native?: true | undefined;
139
+ isolated: boolean;
140
+ directory: string;
133
141
  sessionID: string;
134
142
  title: string;
135
143
  })[];
@@ -1,23 +1,65 @@
1
- import type { Artifacts, CleanupResult, ExecutionError, HeldReport, LeftMember, Limits, Sent } from "./ports.js";
1
+ import { type ModelRef } from "./domain.js";
2
+ import type { Artifacts, CleanupResult, ExecutionError, HeldReport, LeftMember, Limits, Sent, Standing } from "./ports.js";
2
3
  /** The branch an isolated child commits its work on, and names in the `branch` of its report. */
3
4
  export declare const childBranch: (sessionID: string) => string;
4
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. */
5
6
  export declare function childBrief(parentID: string, task: string, depth: number, limits: Limits, branch?: string): string;
6
- /** 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. */
7
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";
8
11
  /**
9
12
  * The system part naming a session's place in its tree: `depth` 0 for a session nobody spawned that
10
13
  * has started one, `null` for one that has started none, which is pointed to the skill. The same on every request.
11
14
  */
12
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;
13
47
  export declare const depthRefusal: (depth: number, maxDepth: number) => string;
14
48
  export declare const childrenRefusal: (running: number, maxChildren: number) => string;
15
49
  export declare const totalRefusal: (running: number, maxTotal: number) => string;
50
+ export declare const modelFormatRefusal: (given: string) => string;
51
+ /** The model is not among those OpenCode lists; `named` says the caller named it, rather than inherited it and named a variant. */
52
+ export declare function unknownModelRefusal(model: ModelRef, known: ReadonlyArray<Pick<ModelRef, "providerID" | "id">>, named: boolean): string;
53
+ export declare function unknownVariantRefusal(model: ModelRef, variants: ReadonlyArray<string>): string;
54
+ /** A variant was named, but the model it would apply to could not be read: the named agent's, else the caller's own or OpenCode's default. */
55
+ export declare function variantWithoutModelRefusal(variant: string, agent?: string): string;
16
56
  /**
17
57
  * The body of a report: its text, then the artifacts in a fixed layout, the same for every report,
18
58
  * so a parent finds the branch, the commits, the files and the checks without reading prose.
19
59
  */
20
60
  export declare function reportBody(message: string, artifacts: Artifacts | undefined): string;
61
+ /** courier_spawn's refusal of `native: true` with `isolate: true`: OpenCode gives a session a worktree or a parent, never both. */
62
+ export declare const nativeIsolateRefusal: () => string;
21
63
  export declare function spawnText(child: {
22
64
  readonly sessionID: string;
23
65
  readonly directory: string;
@@ -26,11 +68,11 @@ export declare function spawnText(child: {
26
68
  readonly groupError?: string;
27
69
  readonly fromParent?: boolean;
28
70
  readonly uncommitted?: boolean;
71
+ /** The model it runs on, when that is not the caller's own. */
72
+ readonly model?: string;
29
73
  }): string;
30
- /** courier_send's result: for a message to the session that started the sender, whether it was its report. */
31
- export declare function sendText(sessionID: string, sent?: Pick<Sent, "status" | "report" | "held">): string;
32
- /** courier_status's result: the session's state, as JSON. */
33
- 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;
34
76
  export declare const childrenText: (listed: ReadonlyArray<unknown>) => string;
35
77
  export declare function cleanupText(result: CleanupResult): string;
36
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. */
@@ -19,6 +19,11 @@ export function childBrief(parentID, task, depth, limits, branch) {
19
19
  "concrete ask, then end your turn and wait for its answer; never end quietly.",
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
+ "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), 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.",
22
27
  "If you need the person to decide something, use your question tool; it reaches them through the session that started you.",
23
28
  "",
24
29
  ...(branch ? [...isolationRule(branch, depth < limits.maxDepth), ""] : []),
@@ -48,16 +53,20 @@ function splitRule(parentID, depth, limits) {
48
53
  `You are at depth ${depth} of a session tree that goes at most ${limits.maxDepth} deep. Split your task or do it yourself:`,
49
54
  "- Split it when it has 2 or more independent parts, each substantial and touching separate files or areas: start one " +
50
55
  `session per part with courier_spawn, at most ${limits.maxChildren} of yours running at once and ${limits.maxTotal} in the whole tree.`,
51
- "- Do it yourself when it is small, sequential or tightly coupled. Never start exactly one session for the whole task; a core you stage before the rest is one part of it, not the whole, when 2 or more parts follow it.",
56
+ "- Do it yourself when it is small, sequential or tightly coupled. Never start exactly one session for the whole task. One session for one stage is a part, not the whole: " +
57
+ "a core you stage before the rest, when 2 or more parts follow it; a review of the parts once you have merged them; the fixing of what a review found once you have verified it. " +
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.",
52
59
  `- Say which in one line of your reply: ${SPLIT_LINE}.`,
53
60
  "- If you split, you orchestrate: start the parts that run at once in one group, with the same group name for each, " +
54
- "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 " +
55
62
  "reports wake you. Check and integrate each part yourself when it reports (never hand that checking to another " +
56
63
  `session), then send ${parentID} one combined report.`,
57
64
  ];
58
65
  }
59
- /** 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. */
60
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";
61
70
  /**
62
71
  * The system part naming a session's place in its tree: `depth` 0 for a session nobody spawned that
63
72
  * has started one, `null` for one that has started none, which is pointed to the skill. The same on every request.
@@ -80,6 +89,67 @@ export function rolePart(depth, limits) {
80
89
  return (`${ROLE_PREFIX} leaf, at depth ${depth}, the deepest the session tree goes. You cannot start sessions: do your ` +
81
90
  "task yourself and report with courier_send to the session that started you.");
82
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
+ }
83
153
  // courier_spawn's refusals, one per limit.
84
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 ` +
85
155
  "(maxDepth), so it cannot start sessions. Do the task yourself, and report with courier_send.";
@@ -89,6 +159,36 @@ export const childrenRefusal = (running, maxChildren) => `Not started: ${count(r
89
159
  `(maxChildren ${maxChildren}). Do this part yourself, or end your turn and start it once one of them has reported.`;
90
160
  export const totalRefusal = (running, maxTotal) => `Not started: ${count(running, "session")} in this session tree ${live(running)}, the most allowed at once ` +
91
161
  `(maxTotal ${maxTotal}). Do this part yourself, or end your turn and try again once a report has arrived.`;
162
+ // courier_spawn's refusals of a model or a variant OpenCode does not list.
163
+ /** How many models, or providers, a refusal names before it counts the rest. */
164
+ const NAMED = 12;
165
+ const some = (items) => (items.length > NAMED ? `${items.slice(0, NAMED).join(", ")} and ${items.length - NAMED} more` : items.join(", "));
166
+ export const modelFormatRefusal = (given) => `Not started: model must be provider/model, as OpenCode names them (opencode/gpt-5-nano, anthropic/claude-sonnet-4-5), not ${JSON.stringify(given)}. ` +
167
+ "Leave it unset to run the session on your own model.";
168
+ /** The model is not among those OpenCode lists; `named` says the caller named it, rather than inherited it and named a variant. */
169
+ export function unknownModelRefusal(model, known, named) {
170
+ const name = `${model.providerID}/${model.id}`;
171
+ const ids = known.filter((item) => item.providerID === model.providerID).map((item) => item.id);
172
+ const providers = [...new Set(known.map((item) => item.providerID))];
173
+ let hint = "No model is listed here at all.";
174
+ if (ids.length)
175
+ hint = `The models of ${model.providerID} here are ${some(ids)}.`;
176
+ else if (providers.length)
177
+ hint = `The providers here are ${some(providers)}.`;
178
+ return named
179
+ ? `Not started: OpenCode lists no model ${name} here. ${hint} Leave model unset to run the session on your own model.`
180
+ : `Not started: the variant cannot be checked, since OpenCode lists no model ${name} here, the one the session would run on. ${hint}`;
181
+ }
182
+ export function unknownVariantRefusal(model, variants) {
183
+ const listed = variants.length ? `${variants.join(", ")} and default` : "default alone";
184
+ return (`Not started: the model ${model.providerID}/${model.id} has no variant ${JSON.stringify(model.variant)}. ` +
185
+ `Its variants are ${listed}; leave variant unset for the model's default.`);
186
+ }
187
+ /** A variant was named, but the model it would apply to could not be read: the named agent's, else the caller's own or OpenCode's default. */
188
+ export function variantWithoutModelRefusal(variant, agent) {
189
+ const why = agent ? `the agent ${agent} could not be looked up` : "neither your own model nor OpenCode's default could be read";
190
+ return `Not started: variant ${JSON.stringify(variant)} needs a model to apply to, and ${why}. Name a model as provider/model, or leave variant unset.`;
191
+ }
92
192
  /** The strings of a list given as artifacts, whatever else the model put in it. */
93
193
  const strings = (value) => (Array.isArray(value) ? value.flatMap((item) => (typeof item === "string" ? [item.trim()] : [])) : []).filter(Boolean);
94
194
  /** A list's line in the artifacts block, or none when it is empty. */
@@ -112,6 +212,9 @@ export function reportBody(message, artifacts) {
112
212
  return lines.length ? [message, "", "Artifacts:", ...lines].join("\n") : message;
113
213
  }
114
214
  // Tool results.
215
+ /** courier_spawn's refusal of `native: true` with `isolate: true`: OpenCode gives a session a worktree or a parent, never both. */
216
+ export const nativeIsolateRefusal = () => "Not started: native: true and isolate: true cannot go together. OpenCode runs a session in a worktree of its own or as a " +
217
+ "subagent of its parent, never both. Start it with isolate: true alone for a worktree of its own, or with native: true alone for a subagent in your directory.";
115
218
  export function spawnText(child) {
116
219
  const warning = (child.fromParent ? " Its worktree starts from the commit your own worktree is on." : "") +
117
220
  (child.uncommitted
@@ -124,11 +227,15 @@ export function spawnText(child) {
124
227
  "turn has ended, you get their reports in one message, which starts a new turn for you; a blocked report, a question " +
125
228
  "or a failed turn still reaches you at once."
126
229
  : "It will report back with courier_send, which starts a new turn for you.";
127
- return (`Started session ${child.sessionID} in ${child.directory}. ${reports} Once you have started every session you need, ` +
230
+ const on = child.model ? `, on ${child.model}` : "";
231
+ return (`Started session ${child.sessionID} in ${child.directory}${on}. ${reports} Once you have started every session you need, ` +
128
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}`);
129
233
  }
130
- /** 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. */
131
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.");
132
239
  const status = sent.status ? `, status ${sent.status}` : "";
133
240
  if (sent.held) {
134
241
  const { group, reported, members } = sent.held;
@@ -146,8 +253,6 @@ export function sendText(sessionID, sent = {}) {
146
253
  return `Delivered to ${sessionID}. Without a status it is progress, not your report: when you finish, send one with status ${STATUS_LIST}.`;
147
254
  return `Delivered to ${sessionID}.`;
148
255
  }
149
- /** courier_status's result: the session's state, as JSON. */
150
- export const statusText = (status) => JSON.stringify(status, null, 2);
151
256
  export const childrenText = (listed) => listed.length ? JSON.stringify(listed, null, 2) : "No sessions started with courier_spawn.";
152
257
  export function cleanupText(result) {
153
258
  if (result.outcome === "kept")
@@ -9,7 +9,10 @@ export declare const SpawnInput: Schema.Struct<{
9
9
  readonly task: Schema.String;
10
10
  readonly title: Schema.optional<Schema.String>;
11
11
  readonly agent: Schema.optional<Schema.String>;
12
+ readonly model: Schema.optional<Schema.String>;
13
+ readonly variant: Schema.optional<Schema.String>;
12
14
  readonly isolate: Schema.optional<Schema.Boolean>;
15
+ readonly native: Schema.optional<Schema.Boolean>;
13
16
  readonly group: Schema.optional<Schema.String>;
14
17
  }>;
15
18
  export declare const SendInput: Schema.Struct<{
@@ -27,9 +30,6 @@ export declare const SendInput: Schema.Struct<{
27
30
  }>>;
28
31
  readonly queue: Schema.optional<Schema.Boolean>;
29
32
  }>;
30
- export declare const StatusInput: Schema.Struct<{
31
- readonly sessionID: Schema.String;
32
- }>;
33
33
  export declare const ChildrenInput: Schema.Struct<{
34
34
  readonly sessionID: Schema.optional<Schema.String>;
35
35
  }>;