opencode-courier 0.12.0 → 0.13.1

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 +10 -9
  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 +7 -7
package/dist/skill.js CHANGED
@@ -15,74 +15,69 @@ export const SKILL_CONTENT = [
15
15
  "if a session started you.",
16
16
  "",
17
17
  "1. Decide: split or do.",
18
- " - At the root, first call courier_recall once: it lists how earlier trees in this project were split and how each " +
19
- "part went, so weigh that; one call, before you decide, never in a loop. Below the root, read none: the records " +
20
- "describe whole trees, and the root weighed them before it split.",
18
+ " - At the root, first call courier_recall once, before you decide and never in a loop: it lists how earlier trees in " +
19
+ "this project were split and how each part went. Below the root, read none: the records describe whole trees, which " +
20
+ "the root weighed before it split.",
21
21
  " - Split when the task has 2 or more independent, substantial parts touching separate files or areas; do it yourself " +
22
- "when it is small, sequential or tightly coupled. A part that is itself big and divisible is split again by the " +
23
- "session that gets it. Several whole jobs in one prompt, such as 4 tickets, are that many parts: each session runs " +
22
+ "when it is small, sequential or tightly coupled. Several whole jobs in one prompt, such as 4 tickets, are that many " +
23
+ "parts: each session runs " +
24
24
  "this playbook for its job, stages, review and all, so the tree below you repeats its shape once per job.",
25
- " - What you read and write yourself stays in your context for every later turn, so keep the deciding, checking and " +
25
+ " - What you read and write yourself stays in your context for every later turn: keep the deciding, checking and " +
26
26
  "integrating, and hand off the long reading and writing.",
27
27
  " - Only the root, the session nobody started, may start exactly one session for the whole task, and only when the " +
28
- "task is heavy (long reading, many edits, long runs) and its own context is worth keeping. It still verifies and " +
29
- "integrates that session's report itself (steps 5 to 7), and does a small task itself.",
30
- " - Every other session never starts exactly one session for the whole task: it only adds a hop, and a chain of single " +
31
- "hops uses up the tree's depth.",
32
- " - Say which in one line of your reply, before you start, so that a reasoned decision to do it yourself can be told " +
33
- `from one never made: ${SPLIT_LINE}; the root has a third form, ${ONE_PART_LINE}.`,
28
+ "task is heavy (long reading, many edits, long runs) and its own context is worth keeping; it still verifies and " +
29
+ "integrates that session's report itself (steps 5 to 7). Every other session never starts exactly one session for " +
30
+ "the whole task: it only adds a hop, and a chain of single hops uses up the tree's depth.",
31
+ ` - Say which in one line of your reply, before you start: ${SPLIT_LINE}; the root has a third form, ${ONE_PART_LINE}.`,
34
32
  " Parts a task can have, besides its edits, each one session where it is worth a context of its own:",
35
33
  " - a scout: a session with isolate: false reads the code, or a library's or a host's source, tries things and " +
36
34
  "reports what it found, before you plan;",
37
35
  " - the implementers, isolate: true where two edit at once, each with its checks and what to report;",
38
36
  " - its e2e scenario, its documentation, sometimes a second test file;",
39
- " - a review stage, after the merge, when the changes are done and it is to see the whole: one review session with " +
40
- "fresh context, which makes no fixes of its own. Put in its task the diff to read: when you are isolated, your own " +
41
- "courier/<session> branch, committed first, against the commit your worktree started from; else your checkout's HEAD, " +
42
- "with the merges committed, against the commit you started from, which you note before your first spawn. It may hand the " +
43
- "reading to 2 or more reviewers (one group, each reads that diff from one angle and reports its findings), then " +
44
- "starts one verifier with their reports: a fresh session that checks each finding against the diff, drops what " +
45
- "does not hold, and starts one implementor for what holds, or 2 or more when the fixes are independent, isolate: " +
46
- "true each then, merging their branches after (step 7); an implementor fixes what it should and refuses, with the " +
47
- "reason, what it should not. Where the code is: when you work in the project's checkout, the review session, the " +
48
- "verifier and a lone implementor go without isolate, and it commits in place; when you are isolated, start each of " +
49
- "them with isolate: true, so that each worktree starts from the one above, and each merges the branch reported to " +
50
- "it before it reports (step 7); reviewers only read and need no worktree. Each reports to the one that started " +
51
- "it; the review session reports to " +
52
- "you what was found, what held, what was fixed (the commits) and what was refused and why. It does not replace " +
53
- "step 6: running the checks on the fixed tree stays yours, and so does settling a refusal you disagree with. Go " +
54
- "through steps 3 to 7 once more for the stage, and do its work yourself when the levels below you are too few for " +
55
- "it. One session for one stage, after a stage of 2 or more parts or one you did yourself, is a part of the task, " +
56
- "like a staged core, not the whole; a reviewer alone with nothing before it is not a stage.",
57
- " When the core is coupled: an implementation task with one tightly coupled core usually still has parts from that " +
58
- "list; weigh each of them. When the other parts depend on names and texts the core fixes, split in stages: the core " +
59
- "first, yourself when it is small or by one session when it is big, merged and committed before the rest start; then, " +
60
- "if 2 or more parts remain, one session per remaining part, in one group. A staged core is one part of the task, not " +
61
- "the whole task, so the rule against starting exactly one session for the whole task does not apply to it when 2 or " +
62
- "more parts follow it.",
37
+ " - a review stage, after the merge, to see the whole: one review session with fresh context, which makes no fixes " +
38
+ "of its own. Put in its task the diff to read: your checkout's HEAD, with the " +
39
+ "merges committed, against the commit you started from, which you note before your first spawn; when you are " +
40
+ "isolated, your own courier/<session> branch, committed first, against the commit your worktree started from. It " +
41
+ "may hand the reading to 2 or more reviewers (one group, each reads the diff from one angle and reports its " +
42
+ "findings), then starts one verifier with their reports: a fresh session that checks each finding against the diff, " +
43
+ "drops what does not hold, and starts one implementor for what holds, or 2 or more with isolate: true when the fixes " +
44
+ "are independent, merging their branches after (step 7); an implementor fixes what it should and refuses, with the " +
45
+ "reason, what it should not. In the project's checkout, the review session, the verifier and a lone implementor go " +
46
+ "without isolate and commit in place; when you are isolated, start each of them with isolate: true, so that each " +
47
+ "worktree starts from the one above; reviewers only read and need no worktree. The review session reports to you " +
48
+ "what was found, what held, what was fixed (the commits) and what was refused and why. It does not replace step 6: " +
49
+ "running the checks on the fixed tree stays yours, and so does settling a refusal you disagree with. Go through " +
50
+ "steps 3 to 7 once more for the stage, and do its work yourself when the levels below you are too few for it. One " +
51
+ "session for one stage, after a stage of 2 or more parts or one you did yourself, is a part of the task, not the " +
52
+ "whole; a reviewer alone with nothing before it is not a stage.",
53
+ " When the core is coupled: a task with one tightly coupled core usually still has parts from that list; weigh each. " +
54
+ "When the rest depend on names and texts the core fixes, split in stages: the core first, yourself when it is small " +
55
+ "or by one session when it is big, merged and committed before the rest start; then, if 2 or more parts remain, one " +
56
+ "session per part, in one group. A staged core is one part of the task, not the whole task, when 2 or more parts " +
57
+ "follow it.",
63
58
  "2. Plan the parts so that no two edit the same files. Write each task so the session can do it without you: what to " +
64
59
  "produce and where, how to check it, what to report. Say if it may split its part further, and to run its checks " +
65
- "through the project's scripts (step 6): each permission request of a session below you wakes you and waits on the person. " +
60
+ "through the project's scripts (step 6), since each permission request below you wakes you and waits on the person. " +
66
61
  "Name the skills it should load for its part, from those listed to you: it sees the same list, not your choice; one " +
67
62
  "permission rule for skill covers their loads, as one covers the checks. Say in the task when the part is a whole job " +
68
63
  "that delivers itself (step 7), since an isolated session's brief otherwise says its branch is for you to merge.",
69
- "3. Start them: one courier_spawn per part that runs at once, all with the same group name, so you are woken once with every report; " +
70
- "isolate: true for each part that edits files in parallel with another (commit your own work first if you are isolated " +
71
- "yourself). When each part runs a review stage of its own, count 6 live sessions per part against maxTotal (a part, " +
72
- "its review session and its reviewers are live together at the peak): start the parts that fit in one group, and the " +
73
- "rest in a group of their own once the first group has reported. A job that depends on another starts after that one " +
74
- "is done, as a stage: merged into your checkout, or, when it was delivered instead (step 7), started with isolate: " +
75
- "true and told the delivering session's courier/<session> branch, from its report, to merge into its worktree first. " +
64
+ "3. Start them: one courier_spawn per part that runs at once, all with the same group name, so that you are woken once " +
65
+ "with every report, and isolate: true for each part that edits files in parallel with another. When each part runs " +
66
+ "a review stage of its own, count 6 live sessions per part against maxTotal (a part, its review session and its " +
67
+ "reviewers are live together at the peak): start the parts that fit in one group, and the rest in a group of their " +
68
+ "own once the first group has reported. A job that depends on another starts after that one is done, as a stage: " +
69
+ "merged into your checkout, or, when it was delivered instead (step 7), started with isolate: true and told the " +
70
+ "delivering session's courier/<session> branch, from its report, to merge first. " +
76
71
  "Leave model and variant unset, so a part runs on your own model, unless the part plainly calls for another: a " +
77
72
  "cheaper model or a lower variant for a small, well-specified part (a rename, a lookup), a stronger model or a higher " +
78
73
  "variant for a design question or a hard bug. " +
79
74
  "native: true for a part nobody needs to open on its own: it reads, checks or looks something up, reports once and is " +
80
75
  "done; leave it off for a part the person may want to open, or that should outlive you. " +
81
76
  "For long work, courier_later a check-in for yourself, and courier_cancel it when the reports arrive.",
82
- "4. End your turn: reply without calling more tools. Do not poll with courier_status, courier_children, courier_tree or " +
83
- "sleep. The group's reports, a blocked report, a question, a permission request, a failed turn or a silent end each start " +
84
- "a new turn for you. So does a session's message without a status, and one from the person or from the session that " +
85
- "started you.",
77
+ "4. End your turn: reply without calling more tools, and do not poll with courier_children, courier_tree " +
78
+ "or sleep. The group's reports, a blocked report, a message without a status, a question, a permission request, a " +
79
+ "failed turn or a silent end each start a new turn for you; so does a message from the person or from the session " +
80
+ "that started you.",
86
81
  " Answering from below: follow the notice. For a permission request, propose once with courier_answer, or reject with " +
87
82
  "the reason when the person has told you to refuse it or it plainly goes against what they asked; the person decides, " +
88
83
  "in OpenCode's prompt in your session. For a question, ask the person with your question tool, with the questions the " +
@@ -91,8 +86,10 @@ export const SKILL_CONTENT = [
91
86
  " Passing a message on: when it changes what other parts need (an assumption of the split that is wrong, a shared file " +
92
87
  "or API that behaves otherwise, a decision of the person's), pass it on at once with courier_send, only to the sessions " +
93
88
  "it affects, before they go further, then end your turn again; if it breaks the split, courier_stop the parts it breaks " +
94
- "and plan them again, or send them a corrected task. If a session started you and its other parts need it too, send it " +
95
- "up to that session as well, without a status. Progress that changes nothing for the others goes to no one.",
89
+ "and plan them again, or send them a corrected task. Send it up too, without a status, when the other parts of the " +
90
+ "session that started you need it. Progress that changes nothing for the others goes to no one. A session's account " +
91
+ "of what the person told it is the person's word: pass it on to the parts it affects, do not judge it against the " +
92
+ "original task, and never tell a session to undo what the person asked.",
96
93
  "5. Read each report by its status and its artifacts (branch, commits, files, checks). done: verify it. partial: what is " +
97
94
  "left and why; have the session finish it with a courier_send, or do it yourself. blocked: make the decision it asks for " +
98
95
  "and send it with courier_send; if only the person can make it, ask them with your question tool and pass the answer on. " +
@@ -106,16 +103,16 @@ export const SKILL_CONTENT = [
106
103
  "7. Integrate: merge each reported branch into your own checkout (git merge courier/<session>), resolve conflicts " +
107
104
  "yourself, run the checks once more, then courier_cleanup the isolated sessions you are done with. When the parts are " +
108
105
  "whole jobs with a deliverable each (a ticket with its own branch or pull request), the session delivers it and " +
109
- "reports the link in its message, still naming its courier/<session> branch when it is isolated; you verify each " +
110
- "delivery and merge nothing of it into your checkout.",
106
+ "reports the link, still naming its courier/<session> branch when it is isolated; you verify each delivery and merge " +
107
+ "nothing of it into your checkout.",
111
108
  "8. Report upward if a session started you: one courier_send to it, with one status for the whole (done only when every " +
112
- "part is verified and integrated, or verified and delivered; partial, blocked or failed otherwise) and the artifacts. At the top, before you reply " +
113
- "to the person: if there is something the next tree in this project should know about running a tree like this one (how " +
114
- "it was split, what a part got wrong and what caught it, what a brief should have said, what the person decided), one " +
115
- "courier_note with that one line; none when there is nothing. Name the job it comes from and the reason, so that a " +
116
- "lesson for one kind of job is not taken for every job; the tool refuses a line over its limit rather than clipping " +
117
- "it, so write it again shorter. A rule that holds every time goes to AGENTS.md or the skill, through the person, not " +
118
- "into a note. Then reply to the person.",
109
+ "part is verified and integrated, or verified and delivered; partial, blocked or failed otherwise) and the artifacts. " +
110
+ "At the top, before you reply to the person: if there is something the next tree in this project should know about " +
111
+ "running a tree like this one (how it was split, what a part got wrong and what caught it, what a brief should have " +
112
+ "said, what the person decided), one courier_note with that one line; none when there is nothing. Name the job it " +
113
+ "comes from and the reason, so that a lesson for one kind of job is not taken for every job; the tool refuses a line " +
114
+ "over its limit rather than clipping it, so write it again shorter. A rule that holds every time goes to AGENTS.md or " +
115
+ "the skill, through the person, not into a note. Then reply to the person.",
119
116
  "",
120
117
  "When a part goes wrong:",
121
118
  "- A failed turn (failed=\"...\") or a silent end (ended=\"without-report\", quoting the session's last reply): message " +
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { isAbsolute } from "node:path";
4
4
  import { forgetDropped } from "./forget.js";
5
+ import { syncStanding } from "./standing.js";
5
6
  import { childStore } from "./store.js";
6
7
  import { keepReason } from "./text.js";
7
8
  /** At most this many changes and commits are returned; the reason still counts them all. */
@@ -18,6 +19,7 @@ export async function cleanup(ports, parentID, input) {
18
19
  // A member still out is dropped from its group, so the group is released without it; the result names that group.
19
20
  const forget = async () => {
20
21
  const group = await forgetDropped(ports, entry);
22
+ await syncStanding(ports, parentID);
21
23
  return group ? { group } : {};
22
24
  };
23
25
  if (!entry.isolated)
@@ -9,7 +9,7 @@ export interface Shaped {
9
9
  }>;
10
10
  }
11
11
  /**
12
- * The `context` hook: names a session's role in its tree in a system part, and hides courier_spawn, and the tools
13
- * named `hiddenFromLeaf`, from one at `maxDepth`. A session nobody spawned that has started none is pointed to the skill, if it could spawn.
12
+ * The `context` hook: hides courier_spawn and the tools named `hiddenFromLeaf` from a session at `maxDepth`, and names a
13
+ * session's role in a system part unless its `role` entry does; a session nobody spawned that has started none is pointed to the skill.
14
14
  */
15
15
  export declare function shapeContext(ports: ContextPorts, event: Shaped): Promise<void>;
@@ -1,28 +1,31 @@
1
1
  import { setBounded } from "../platform/bounded.js";
2
2
  import { childStore } from "./store.js";
3
- import { ROLE_PREFIX, rolePart } from "./text.js";
4
- /** How many sessions' depths an instance remembers. */
3
+ import { ROLE_KEY, ROLE_PREFIX, rolePart } from "./text.js";
4
+ /** How many sessions' roles an instance remembers. */
5
5
  const ROLES_MAX = 1000;
6
+ /** A role part, or the role entry's block, already in the system prompt, wherever its part begins. */
7
+ const ROLE_LINE = new RegExp(`^${ROLE_PREFIX}`, "m");
6
8
  /**
7
- * The `context` hook: names a session's role in its tree in a system part, and hides courier_spawn, and the tools
8
- * named `hiddenFromLeaf`, from one at `maxDepth`. A session nobody spawned that has started none is pointed to the skill, if it could spawn.
9
+ * The `context` hook: hides courier_spawn and the tools named `hiddenFromLeaf` from a session at `maxDepth`, and names a
10
+ * session's role in a system part unless its `role` entry does; a session nobody spawned that has started none is pointed to the skill.
9
11
  */
10
12
  export async function shapeContext(ports, event) {
11
13
  try {
12
- const depth = await depthOf(ports, event.sessionID);
14
+ const { depth, entry } = await roleOf(ports, event.sessionID);
13
15
  if (depth === null && !event.tools.courier_spawn)
14
16
  return;
15
17
  if (depth !== null && depth >= ports.limits.maxDepth)
16
18
  for (const tool of ["courier_spawn", ...(ports.hiddenFromLeaf ?? [])])
17
19
  delete event.tools[tool];
18
- if (!event.system.some((part) => part.text.startsWith(ROLE_PREFIX)))
19
- event.system.push({ type: "text", text: rolePart(depth, ports.limits) });
20
+ if (entry || event.system.some((part) => ROLE_LINE.test(part.text)))
21
+ return;
22
+ event.system.push({ type: "text", text: rolePart(depth, ports.limits) });
20
23
  }
21
24
  catch (error) {
22
25
  ports.log(`courier: cannot tell the role of session ${event.sessionID}: ${String(error)}`);
23
26
  }
24
27
  }
25
- async function depthOf(ports, sessionID) {
28
+ async function roleOf(ports, sessionID) {
26
29
  const known = ports.roles.get(sessionID);
27
30
  if (known !== undefined)
28
31
  return known;
@@ -30,6 +33,19 @@ async function depthOf(ports, sessionID) {
30
33
  const children = childStore(ports.storage);
31
34
  const chain = await children.indexedLineage(sessionID);
32
35
  const depth = chain.length || ((await children.children(sessionID)).length ? 0 : null);
33
- setBounded(ports.roles, sessionID, depth, ROLES_MAX);
34
- return depth;
36
+ const role = { depth, entry: depth !== null && (await hasEntry(ports, sessionID)) };
37
+ setBounded(ports.roles, sessionID, role, ROLES_MAX);
38
+ return role;
39
+ }
40
+ /** Whether the session carries the role entry, listed when its role is looked up; one that cannot be read gets the part. */
41
+ async function hasEntry(ports, sessionID) {
42
+ if (!ports.instructions)
43
+ return false;
44
+ try {
45
+ return (await ports.instructions.entry.list({ sessionID })).some((entry) => entry.key === ROLE_KEY);
46
+ }
47
+ catch (error) {
48
+ ports.log(`courier: cannot read the instruction entries of session ${sessionID}, so its role goes in a system part: ${String(error)}`);
49
+ return false;
50
+ }
35
51
  }
@@ -73,6 +73,18 @@ export declare function cut<T extends Cuttable>(nodes: ReadonlyArray<T>, max: nu
73
73
  kept: T[];
74
74
  leftOut: number;
75
75
  };
76
+ /**
77
+ * Entries older than this are dropped when their parent's roster is read, and on plugin setup,
78
+ * except isolated children whose worktree is still there: those stay until courier_cleanup.
79
+ */
80
+ export declare const RETENTION_MS: number;
81
+ /** Whether a directory still exists; tests pass a fake. */
82
+ export type Exists = (directory: string) => boolean;
83
+ /**
84
+ * Whether an entry is past retention at `now`: recorded more than `RETENTION_MS` before, unless it is an isolated
85
+ * child whose worktree exists, which is kept so it can still be cleaned up.
86
+ */
87
+ export declare const isExpired: (entry: RosterEntry, now: number, exists: Exists) => boolean;
76
88
  /** A session started with courier_spawn, recorded under the session that started it. */
77
89
  export interface RosterEntry {
78
90
  readonly sessionID: string;
@@ -70,6 +70,16 @@ export function cut(nodes, max) {
70
70
  }
71
71
  return { kept: nodes.filter((node) => kept.has(node.sessionID)), leftOut: nodes.length - kept.size };
72
72
  }
73
+ /**
74
+ * Entries older than this are dropped when their parent's roster is read, and on plugin setup,
75
+ * except isolated children whose worktree is still there: those stay until courier_cleanup.
76
+ */
77
+ export const RETENTION_MS = 14 * 24 * 60 * 60_000;
78
+ /**
79
+ * Whether an entry is past retention at `now`: recorded more than `RETENTION_MS` before, unless it is an isolated
80
+ * child whose worktree exists, which is kept so it can still be cleaned up.
81
+ */
82
+ export const isExpired = (entry, now, exists) => now - entry.createdAt > RETENTION_MS && !(entry.isolated && exists(entry.directory));
73
83
  /** How a child was started, as courier_children and the panel show it: isolated, native when it is, and its model when not its parent's. */
74
84
  export function startedAs(entry) {
75
85
  return { isolated: entry.isolated, ...(entry.native ? { native: true } : {}), ...(entry.model ? { model: entry.model } : {}) };
@@ -4,6 +4,7 @@ import { endsIdle, retryDelay } from "./domain.js";
4
4
  import { forgetChild, forgetDeleted } from "./forget.js";
5
5
  import { hasCompleteGroup, leave, outIn } from "./group.js";
6
6
  import { awaited, reportOf, stoppedSincePrompt } from "./report.js";
7
+ import { syncStanding } from "./standing.js";
7
8
  import { childStore, groupStore } from "./store.js";
8
9
  import { lastReply } from "./status.js";
9
10
  import { failureNotice, silentNotice } from "./text.js";
@@ -43,6 +44,7 @@ export async function reportFailure(ports, seen, event) {
43
44
  finally {
44
45
  if (completed.includes(true))
45
46
  ports.nudge();
47
+ await Promise.all(entries.map((entry) => syncStanding(ports, entry.parentID)));
46
48
  }
47
49
  return entries.map((entry) => entry.parentID);
48
50
  }
@@ -66,9 +68,11 @@ export async function notePrompt(ports, event, inbox) {
66
68
  if (inboxID !== undefined && UNOWED.has(inbox?.get(inboxID) ?? ""))
67
69
  return false;
68
70
  const children = childStore(ports.storage);
69
- if ((await children.indexedParent(sessionID)) === undefined)
71
+ const parentID = await children.indexedParent(sessionID);
72
+ if (parentID === undefined)
70
73
  return false;
71
74
  await children.prompted(sessionID, event.created ?? ports.now());
75
+ await syncStanding(ports, parentID);
72
76
  return true;
73
77
  }
74
78
  /**
@@ -88,6 +92,7 @@ export async function noteInterrupted(ports, event) {
88
92
  const member = await outIn(ports, entry);
89
93
  if (member && (await leave(ports, member, "interrupted", at)))
90
94
  ports.nudge();
95
+ await syncStanding(ports, entry.parentID);
91
96
  return true;
92
97
  }
93
98
  /**
@@ -104,6 +109,8 @@ export async function noteDeleted(ports, event) {
104
109
  await forgetDeleted(ports, sessionID, forgotten, member, event.created);
105
110
  if (parentID !== undefined && entry && !entry.isolated && started.length === 0)
106
111
  await forgetChild(ports.storage, parentID, sessionID);
112
+ if (parentID !== undefined)
113
+ await syncStanding(ports, parentID);
107
114
  return forgotten;
108
115
  }
109
116
  /** A `Waits` asking `suppliers` one after another, cheapest first: the first that waits answers. */
@@ -165,6 +172,7 @@ export async function reportSilent(ports, seen, event, outside) {
165
172
  await children.untold(sessionID, toldAt).catch(() => undefined);
166
173
  throw error;
167
174
  }
175
+ await syncStanding(ports, entry.parentID);
168
176
  return [entry.parentID];
169
177
  }
170
178
  /**
@@ -179,6 +187,17 @@ export async function reportDeclined(ports, seen, event, outside) {
179
187
  return reportSilent(ports, seen, event, outside);
180
188
  }
181
189
  const TURN_ENDS = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"]);
190
+ /** What `route` and `after` dispatch on; the wake watch needs `inbox.enqueued` and `execution.started` both. */
191
+ const TREE_TYPES = [
192
+ "session.execution.started",
193
+ "session.execution.succeeded",
194
+ "session.execution.failed",
195
+ "session.execution.interrupted",
196
+ "session.inbox.enqueued",
197
+ "session.inbox.delivered",
198
+ "session.inbox.cancelled",
199
+ "session.deleted",
200
+ ];
182
201
  /** A session's turn ended, sealing its groups: one that is complete has the scheduler deliver it now; not a shutdown's, which stamps no idle time. */
183
202
  async function sealGroups(ports, event) {
184
203
  if (event.data.reason === "shutdown")
@@ -239,6 +258,7 @@ async function route(ports, state, outside, event) {
239
258
  */
240
259
  export function treeEvents(state, outside) {
241
260
  return {
261
+ types: TREE_TYPES,
242
262
  handle: (ports, event) => route(ports, state, outside, event),
243
263
  // After: a silent end is judged while the group the nudge may deliver is still there to wait on.
244
264
  after: (ports, event) => (TURN_ENDS.has(event.type) ? sealGroups(ports, event) : undefined),
@@ -1,6 +1,7 @@
1
1
  import { isNotFound, millis } from "../platform/json.js";
2
2
  import { envelope } from "../kernel/protocol.js";
3
3
  import { complete, standingOf } from "./domain.js";
4
+ import { syncStanding } from "./standing.js";
4
5
  import { groupStore } from "./store.js";
5
6
  import { groupNotice } from "./text.js";
6
7
  /** Makes a spawned session a member of its parent's group, opening the group if it was not. */
@@ -182,6 +183,8 @@ async function release(ports, parentID, group) {
182
183
  }
183
184
  }
184
185
  await drop(ports, parentID, group, members);
186
+ // Delivered: the members read as released in the parent's standing, which its next step shows.
187
+ await syncStanding(ports, parentID);
185
188
  }
186
189
  /** Drops a group's members once delivered, or unsent, saying why. */
187
190
  async function drop(ports, parentID, group, members, unsent) {
@@ -4,7 +4,7 @@ export { endsIdle, lineageIn, nativeRootOf, readLimits, readNativeChildren } fro
4
4
  export { together, treeEvents, waitsOn } from "./events.js";
5
5
  export { deliverReleased } from "./group.js";
6
6
  export { contextOf } from "./panel.js";
7
- export type { CleanupPorts, CourierPorts, ModelWindow, Pending, PendingPrompt, RosterEntry, RosterStorage, ShownForm, SpawnGate, Waits, WatchPorts, WatchState, } from "./ports.js";
7
+ export type { CleanupPorts, CourierPorts, Entries, Entry, Instructions, ModelWindow, Pending, PendingPrompt, Role, RosterEntry, RosterStorage, ShownForm, SpawnGate, Waits, WatchPorts, WatchState, } from "./ports.js";
8
8
  export { allEntries, answeringTop, childrenOf, indexedLineage, lineage, pruneExpired, reportState, RETENTION_MS, type ReportState } from "./roster.js";
9
9
  export { runs, turnIn, turnOf, type Turn } from "./running.js";
10
10
  export { subtree, type Below } from "./subtree.js";
@@ -43,6 +43,8 @@ export interface ChildStore {
43
43
  entriesOf(sessionID: string): Promise<RosterEntry[]>;
44
44
  /** The session that started a session, by the reverse index alone, without scanning the roster. */
45
45
  indexedParent(sessionID: string): Promise<string | undefined>;
46
+ /** The sessions above a session, its parent first, by the reverse index alone: empty when it does not name the session. */
47
+ indexedAncestors(sessionID: string): Promise<ReadonlyArray<string>>;
46
48
  /** A session's roster entry, by the reverse index alone, without scanning the roster. */
47
49
  indexedEntry(sessionID: string): Promise<RosterEntry | undefined>;
48
50
  /**
@@ -149,10 +151,42 @@ export interface Sent {
149
151
  readonly status?: Status;
150
152
  readonly report?: boolean;
151
153
  readonly held?: Held;
154
+ /** The status it was sent with to a session below the sender, where a status means nothing. */
155
+ readonly dropped?: Status;
156
+ }
157
+ /**
158
+ * What the `context` hook remembers of a session: its depth, null for one nobody spawned that has started none, and
159
+ * whether it carries the role entry (`ROLE_KEY`), which then names its role in place of the hook's system part.
160
+ */
161
+ export interface Role {
162
+ readonly depth: number | null;
163
+ readonly entry: boolean;
164
+ }
165
+ /** The keyed session entries, missing on a host before 2.2.0 (every copy's ports carry the host's `session`): an entry is then skipped. */
166
+ export type Instructions = Partial<Pick<Context["session"], "instructions">>;
167
+ /** A keyed session instruction entry: its key, `^[a-z0-9][a-z0-9._-]*$`, and its text. */
168
+ export interface Entry {
169
+ readonly key: string;
170
+ readonly value: string;
171
+ }
172
+ /**
173
+ * Supplies the entries other modules write to a spawned session below `maxDepth` before its prompt, and to a root at its
174
+ * first spawn beside its role; `src/index.ts` composes it from the modules' suppliers, as it does the `Waits` port.
175
+ */
176
+ export type Entries = () => Promise<ReadonlyArray<Entry>>;
177
+ /** What writing a parent's `standing` entry needs: the tools', the watcher's and the scheduler's ports all have these. */
178
+ export interface StandingPorts {
179
+ readonly storage: RosterStorage;
180
+ readonly now: () => number;
181
+ readonly session?: Instructions;
182
+ readonly log?: (message: string) => void;
152
183
  }
153
184
  /** The slice of the plugin context the courier tools use; tests pass a fake. */
154
185
  export interface CourierPorts {
155
- readonly session: Pick<Context["session"], "create" | "prompt" | "synthetic" | "get" | "context" | "interrupt">;
186
+ /** `instructions`, the keyed session entries, is missing on a host before 2.2.0: the role then goes in a system part. */
187
+ readonly session: Pick<Context["session"], "create" | "prompt" | "synthetic" | "get" | "context" | "interrupt"> & Instructions;
188
+ /** The entries of other modules a spawned session gets before its prompt, the memory's lessons; none when not given. */
189
+ readonly entries?: Entries;
156
190
  readonly agent: Pick<Context["agent"], "get">;
157
191
  readonly worktree: Pick<Context["worktree"], "create" | "remove">;
158
192
  /** The project of the plugin's location; an isolated child's worktree is made in it, and removed from it. */
@@ -176,8 +210,8 @@ export interface CourierPorts {
176
210
  /** The `nativeChildren` option: a child started without isolate and without `native` is created with OpenCode's own `parentID`. */
177
211
  readonly nativeChildren?: boolean;
178
212
  readonly gate: SpawnGate;
179
- /** The depths the `context` hook remembers; a parent's is dropped when it starts a child. */
180
- readonly roles: Map<string, number | null>;
213
+ /** The roles the `context` hook remembers; a parent's is dropped when it starts a child, a root's kept once its entry is written. */
214
+ readonly roles: Map<string, Role>;
181
215
  /** Has the scheduler deliver what is due now rather than at its next tick: a group a report has just completed. */
182
216
  readonly nudge: () => void;
183
217
  readonly log: (message: string) => void;
@@ -276,6 +310,9 @@ export interface CleanupPorts {
276
310
  readonly now: () => number;
277
311
  /** Has the scheduler deliver what is due now: a group the forgotten child may have been the last member out of. */
278
312
  readonly nudge: () => void;
313
+ /** For the parent's `standing` entry once the child is forgotten; without them it is left as it was. */
314
+ readonly session?: Instructions;
315
+ readonly log?: (message: string) => void;
279
316
  }
280
317
  export interface StopPorts {
281
318
  readonly courier: CourierPorts;
@@ -285,7 +322,8 @@ export interface StopPorts {
285
322
  }
286
323
  export interface GroupPorts {
287
324
  readonly storage: Storage;
288
- readonly session: Pick<Context["session"], "synthetic" | "get">;
325
+ readonly session: Pick<Context["session"], "synthetic" | "get"> & Instructions;
326
+ readonly now: () => number;
289
327
  readonly log: (message: string) => void;
290
328
  }
291
329
  /** The error of OpenCode's `session.execution.failed` event. */
@@ -299,7 +337,7 @@ export interface ExecutionError {
299
337
  export interface WatchPorts {
300
338
  readonly storage: RosterStorage;
301
339
  /** `context` reads the last reply of a session that ended its turn without reporting; `get` is what every member joins with too. */
302
- readonly session: Pick<Context["session"], "synthetic" | "context" | "get">;
340
+ readonly session: Pick<Context["session"], "synthetic" | "context" | "get"> & Instructions;
303
341
  readonly event: Pick<Context["event"], "subscribe">;
304
342
  /** The permission domains of every loaded location, which the `Waits` suppliers ask; an isolated child's are in its worktree's. */
305
343
  readonly permissions: () => Iterable<Pick<Context["permission"], "list">>;
@@ -326,8 +364,10 @@ export type Waits = (ports: Pick<WatchPorts, "storage" | "permissions">, session
326
364
  export interface ContextPorts {
327
365
  readonly storage: RosterStorage;
328
366
  readonly limits: Limits;
329
- /** Each session's depth once looked up: null for one nobody spawned that has started none, until it starts one. */
330
- readonly roles: Map<string, number | null>;
367
+ /** Each session's role once looked up; a session nobody spawned reads as depth null until it starts one. */
368
+ readonly roles: Map<string, Role>;
369
+ /** The keyed session entries, listed when a session's role is looked up; missing on a host before 2.2.0, which gets the system part. */
370
+ readonly instructions?: Context["session"]["instructions"];
331
371
  /** Tools of other modules hidden from a leaf alongside courier_spawn; `src/index.ts` names courier_recall and courier_note. */
332
372
  readonly hiddenFromLeaf?: ReadonlyArray<string>;
333
373
  readonly log: (message: string) => void;
@@ -1,10 +1,7 @@
1
1
  import { type Status } from "../kernel/protocol.js";
2
+ import { type Exists } from "./domain.js";
2
3
  import type { RosterEntry, RosterStorage } from "./ports.js";
3
- /**
4
- * Entries older than this are dropped when their parent's roster is read, and on plugin setup,
5
- * except isolated children whose worktree is still there: those stay until courier_cleanup.
6
- */
7
- export declare const RETENTION_MS: number;
4
+ export { RETENTION_MS, type Exists } from "./domain.js";
8
5
  /** Every parent's children. */
9
6
  export declare function allEntries(storage: RosterStorage): Promise<RosterEntry[]>;
10
7
  /**
@@ -33,12 +30,7 @@ export declare function reportState(storage: RosterStorage, sessionID: string):
33
30
  * person is there. Throws unless the session was spawned and `callerID` is that top; `what` names it.
34
31
  */
35
32
  export declare function answeringTop(storage: RosterStorage, sessionID: string, callerID: string, what: string): Promise<string>;
36
- /** Whether a directory still exists; tests pass a fake. */
37
- export type Exists = (directory: string) => boolean;
38
- /**
39
- * Whether an entry is past retention at `now`: recorded more than `RETENTION_MS` before, unless it is an isolated
40
- * child whose worktree exists, which is kept so it can still be cleaned up.
41
- */
33
+ /** `domain.ts`'s `isExpired`, asking the file system whether an isolated child's worktree is still there unless a test passes a fake. */
42
34
  export declare const isExpired: (entry: RosterEntry, now: number, exists?: Exists) => boolean;
43
35
  /** A parent's children, oldest first, after dropping the expired ones. */
44
36
  export declare function current(storage: RosterStorage, parentID: string, now: number, exists?: Exists): Promise<RosterEntry[]>;
@@ -1,13 +1,10 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { isStatus } from "../kernel/protocol.js";
3
+ import { isExpired as expiredBy } from "./domain.js";
3
4
  import { forgetChild, forgetReports } from "./forget.js";
4
5
  import { stoppedSincePrompt } from "./report.js";
5
6
  import { childStore } from "./store.js";
6
- /**
7
- * Entries older than this are dropped when their parent's roster is read, and on plugin setup,
8
- * except isolated children whose worktree is still there: those stay until courier_cleanup.
9
- */
10
- export const RETENTION_MS = 14 * 24 * 60 * 60_000;
7
+ export { RETENTION_MS } from "./domain.js";
11
8
  /** Every parent's children. */
12
9
  export async function allEntries(storage) {
13
10
  return childStore(storage).all();
@@ -54,11 +51,8 @@ export async function answeringTop(storage, sessionID, callerID, what) {
54
51
  throw new Error(`${sessionID}'s ${what} go to ${top}, the session at the top of the sessions started from it with courier_spawn; ${callerID} cannot answer them.`);
55
52
  return top;
56
53
  }
57
- /**
58
- * Whether an entry is past retention at `now`: recorded more than `RETENTION_MS` before, unless it is an isolated
59
- * child whose worktree exists, which is kept so it can still be cleaned up.
60
- */
61
- export const isExpired = (entry, now, exists = existsSync) => now - entry.createdAt > RETENTION_MS && !(entry.isolated && exists(entry.directory));
54
+ /** `domain.ts`'s `isExpired`, asking the file system whether an isolated child's worktree is still there unless a test passes a fake. */
55
+ export const isExpired = (entry, now, exists = existsSync) => expiredBy(entry, now, exists);
62
56
  /** Forgets the given entries that are expired at `now`, and returns the rest. */
63
57
  async function dropExpired(storage, entries, now, exists) {
64
58
  const expired = new Set(entries.filter((entry) => isExpired(entry, now, exists)));