opencode-courier 0.2.3 → 0.4.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 (143) hide show
  1. package/README.md +198 -193
  2. package/dist/index.d.ts +4 -8
  3. package/dist/index.js +66 -85
  4. package/dist/kernel/protocol.d.ts +20 -0
  5. package/dist/kernel/protocol.js +36 -0
  6. package/dist/kernel/tool.d.ts +5 -0
  7. package/dist/kernel/tool.js +5 -0
  8. package/dist/person/events.d.ts +23 -0
  9. package/dist/person/events.js +113 -0
  10. package/dist/person/form.d.ts +26 -0
  11. package/dist/person/form.js +64 -0
  12. package/dist/person/index.d.ts +17 -0
  13. package/dist/person/index.js +5 -0
  14. package/dist/{relay.d.ts → person/permission.d.ts} +29 -25
  15. package/dist/{relay.js → person/permission.js} +11 -9
  16. package/dist/person/ports.d.ts +58 -0
  17. package/dist/person/ports.js +2 -0
  18. package/dist/{question → person/question}/answer.d.ts +33 -4
  19. package/dist/{question → person/question}/answer.js +49 -17
  20. package/dist/person/question/index.d.ts +7 -0
  21. package/dist/{question → person/question}/index.js +3 -3
  22. package/dist/person/question/lifecycle.d.ts +43 -0
  23. package/dist/{question → person/question}/lifecycle.js +10 -19
  24. package/dist/{question → person/question}/pure.d.ts +4 -6
  25. package/dist/{question → person/question}/pure.js +3 -4
  26. package/dist/{question → person/question}/relay.d.ts +1 -1
  27. package/dist/{question → person/question}/relay.js +27 -35
  28. package/dist/{question → person/question}/runtime.d.ts +2 -3
  29. package/dist/{question → person/question}/runtime.js +2 -3
  30. package/dist/{question → person/question}/shared.d.ts +13 -3
  31. package/dist/person/question/shared.js +52 -0
  32. package/dist/person/question/types.d.ts +121 -0
  33. package/dist/person/question/types.js +1 -0
  34. package/dist/person/rpc-handlers.d.ts +160 -0
  35. package/dist/person/rpc-handlers.js +62 -0
  36. package/dist/person/rpc.d.ts +227 -0
  37. package/dist/person/rpc.js +70 -0
  38. package/dist/person/text.d.ts +68 -0
  39. package/dist/{notices.js → person/text.js} +53 -190
  40. package/dist/person/tools.d.ts +20 -0
  41. package/dist/person/tools.js +55 -0
  42. package/dist/person/tui.d.ts +46 -0
  43. package/dist/platform/bounded.d.ts +12 -0
  44. package/dist/{bounded.js → platform/bounded.js} +6 -4
  45. package/dist/platform/events.d.ts +34 -0
  46. package/dist/platform/events.js +121 -0
  47. package/dist/platform/hub.d.ts +195 -0
  48. package/dist/{hub.js → platform/hub.js} +78 -119
  49. package/dist/platform/json.d.ts +8 -0
  50. package/dist/platform/json.js +15 -0
  51. package/dist/platform/version.d.ts +16 -0
  52. package/dist/{version.js → platform/version.js} +5 -11
  53. package/dist/rpc.d.ts +2 -0
  54. package/dist/rpc.js +3 -0
  55. package/dist/scheduling/index.d.ts +6 -0
  56. package/dist/scheduling/index.js +5 -0
  57. package/dist/{later.d.ts → scheduling/later.d.ts} +13 -9
  58. package/dist/{later.js → scheduling/later.js} +18 -9
  59. package/dist/scheduling/text.d.ts +6 -0
  60. package/dist/scheduling/text.js +10 -0
  61. package/dist/scheduling/tools.d.ts +14 -0
  62. package/dist/scheduling/tools.js +48 -0
  63. package/dist/skill.d.ts +17 -0
  64. package/dist/skill.js +62 -0
  65. package/dist/{cleanup.d.ts → tree/cleanup.d.ts} +5 -26
  66. package/dist/{cleanup.js → tree/cleanup.js} +20 -15
  67. package/dist/tree/context.d.ts +15 -0
  68. package/dist/tree/context.js +34 -0
  69. package/dist/tree/domain.d.ts +101 -0
  70. package/dist/tree/domain.js +68 -0
  71. package/dist/tree/events.d.ts +71 -0
  72. package/dist/tree/events.js +204 -0
  73. package/dist/tree/forget.d.ts +24 -0
  74. package/dist/tree/forget.js +59 -0
  75. package/dist/tree/group.d.ts +82 -0
  76. package/dist/tree/group.js +203 -0
  77. package/dist/tree/index.d.ts +10 -0
  78. package/dist/tree/index.js +11 -0
  79. package/dist/tree/limits.d.ts +10 -0
  80. package/dist/tree/limits.js +60 -0
  81. package/dist/tree/panel.d.ts +13 -0
  82. package/dist/tree/panel.js +75 -0
  83. package/dist/tree/ports.d.ts +276 -0
  84. package/dist/tree/ports.js +1 -0
  85. package/dist/tree/report.d.ts +23 -0
  86. package/dist/tree/report.js +33 -0
  87. package/dist/tree/roster.d.ts +27 -0
  88. package/dist/tree/roster.js +62 -0
  89. package/dist/tree/rpc-handlers.d.ts +201 -0
  90. package/dist/tree/rpc-handlers.js +20 -0
  91. package/dist/tree/rpc.d.ts +344 -0
  92. package/dist/tree/rpc.js +83 -0
  93. package/dist/tree/send.d.ts +14 -0
  94. package/dist/tree/send.js +99 -0
  95. package/dist/tree/spawn.d.ts +21 -0
  96. package/dist/tree/spawn.js +173 -0
  97. package/dist/tree/status.d.ts +128 -0
  98. package/dist/tree/status.js +62 -0
  99. package/dist/tree/stop.d.ts +33 -0
  100. package/dist/tree/stop.js +116 -0
  101. package/dist/tree/store.d.ts +24 -0
  102. package/dist/tree/store.js +264 -0
  103. package/dist/tree/subtree.d.ts +101 -0
  104. package/dist/tree/subtree.js +60 -0
  105. package/dist/tree/summary.d.ts +7 -0
  106. package/dist/tree/summary.js +35 -0
  107. package/dist/tree/text.d.ts +77 -0
  108. package/dist/tree/text.js +287 -0
  109. package/dist/tree/tools.d.ts +54 -0
  110. package/dist/tree/tools.js +167 -0
  111. package/dist/tree/tui.d.ts +53 -0
  112. package/dist/tui.d.ts +3 -0
  113. package/dist/tui.js +1219 -0
  114. package/dist/webhook/index.d.ts +5 -0
  115. package/dist/webhook/index.js +3 -0
  116. package/dist/{webhook.d.ts → webhook/receiver.d.ts} +25 -36
  117. package/dist/{webhook.js → webhook/receiver.js} +66 -67
  118. package/dist/webhook/subscriptions.d.ts +29 -0
  119. package/dist/webhook/subscriptions.js +52 -0
  120. package/dist/webhook/text.d.ts +23 -0
  121. package/dist/webhook/text.js +82 -0
  122. package/dist/webhook/tools.d.ts +19 -0
  123. package/dist/webhook/tools.js +46 -0
  124. package/package.json +18 -7
  125. package/dist/bounded.d.ts +0 -10
  126. package/dist/courier.d.ts +0 -95
  127. package/dist/courier.js +0 -131
  128. package/dist/hub.d.ts +0 -325
  129. package/dist/json.d.ts +0 -4
  130. package/dist/json.js +0 -4
  131. package/dist/notices.d.ts +0 -246
  132. package/dist/question/index.d.ts +0 -6
  133. package/dist/question/lifecycle.d.ts +0 -52
  134. package/dist/question/shared.js +0 -24
  135. package/dist/roster.d.ts +0 -81
  136. package/dist/roster.js +0 -175
  137. package/dist/tools.d.ts +0 -74
  138. package/dist/tools.js +0 -228
  139. package/dist/version.d.ts +0 -22
  140. package/dist/watch.d.ts +0 -61
  141. package/dist/watch.js +0 -289
  142. /package/dist/{storage.d.ts → platform/storage.d.ts} +0 -0
  143. /package/dist/{storage.js → platform/storage.js} +0 -0
@@ -0,0 +1,8 @@
1
+ /** Readers for untyped JSON, such as a webhook payload or a plugin option: the value when it has the type, else nothing. */
2
+ export declare const str: (value: unknown) => string | undefined;
3
+ export declare const num: (value: unknown) => number | undefined;
4
+ export declare const obj: (value: unknown) => Record<string, any>;
5
+ /** Epoch milliseconds from a session time, a `DateTime` in OpenCode's API and a number in tests; NaN for none. */
6
+ export declare function millis(value: unknown): number;
7
+ /** Whether an error of OpenCode's says that what it names, such as a session, does not exist (`Session.NotFoundError`). */
8
+ export declare const isNotFound: (error: unknown) => boolean;
@@ -0,0 +1,15 @@
1
+ /** Readers for untyped JSON, such as a webhook payload or a plugin option: the value when it has the type, else nothing. */
2
+ export const str = (value) => (typeof value === "string" ? value : undefined);
3
+ export const num = (value) => (typeof value === "number" && Number.isInteger(value) ? value : undefined);
4
+ export const obj = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
5
+ /** Epoch milliseconds from a session time, a `DateTime` in OpenCode's API and a number in tests; NaN for none. */
6
+ export function millis(value) {
7
+ if (typeof value === "number")
8
+ return value;
9
+ if (typeof value === "string")
10
+ return Date.parse(value);
11
+ const epoch = obj(value).epochMilliseconds;
12
+ return typeof epoch === "number" ? epoch : Number.NaN;
13
+ }
14
+ /** Whether an error of OpenCode's says that what it names, such as a session, does not exist (`Session.NotFoundError`). */
15
+ export const isNotFound = (error) => str(obj(error)._tag)?.includes("NotFound") ?? false;
@@ -0,0 +1,16 @@
1
+ import type { App } from "@opencode/plugin/app";
2
+ /** The versions a build of the plugin carries: its own, and the OpenCode it was built and tested against. */
3
+ export interface BuiltVersions {
4
+ readonly plugin: string;
5
+ readonly opencode: string;
6
+ }
7
+ /**
8
+ * Reads from the package's own package.json, next to `dist/` as to `src/`: `version` and the exact
9
+ * `@opencode/plugin` devDependency version, which the peer repeats. Throws if either is missing.
10
+ */
11
+ export declare function builtVersions(): BuiltVersions;
12
+ /**
13
+ * The server-log line for an OpenCode other than the plugin was built against, undefined when it is;
14
+ * logged once per load, never in a tool result. A context without a version is still told the pin.
15
+ */
16
+ export declare function versionNotice(built: BuiltVersions, app: Partial<Pick<App, "version" | "channel">> | undefined): string | undefined;
@@ -1,15 +1,11 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  const README_TABLE = "https://github.com/ivopogace/opencode-courier#supported-opencode-version";
3
3
  /**
4
- * Reads the versions from the package's own package.json, which sits next to `dist/` in the
5
- * published package as it does next to `src/` in the repository: `version`, and the exact
6
- * `@opencode/plugin` version under devDependencies, the one installed for the build and both test
7
- * suites; the peer dependency names the same exact version, as the copy of the plugin API that
8
- * `opencode plugin add` installs next to the plugin. Throws when the file cannot be read or does
9
- * not hold both.
4
+ * Reads from the package's own package.json, next to `dist/` as to `src/`: `version` and the exact
5
+ * `@opencode/plugin` devDependency version, which the peer repeats. Throws if either is missing.
10
6
  */
11
7
  export function builtVersions() {
12
- const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
8
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
13
9
  const plugin = pkg.version;
14
10
  const opencode = pkg.devDependencies?.["@opencode/plugin"];
15
11
  if (typeof plugin !== "string")
@@ -19,10 +15,8 @@ export function builtVersions() {
19
15
  return { plugin, opencode };
20
16
  }
21
17
  /**
22
- * The line for the server log when the running OpenCode is not the one the plugin was built
23
- * against; undefined when it is. Logged once per plugin load, never in a tool result. An OpenCode
24
- * whose plugin context does not report a version, as one from before the pinned API or after a
25
- * change to it may not, is the case the line is for, so it still names the pin then.
18
+ * The server-log line for an OpenCode other than the plugin was built against, undefined when it is;
19
+ * logged once per load, never in a tool result. A context without a version is still told the pin.
26
20
  */
27
21
  export function versionNotice(built, app) {
28
22
  const version = typeof app?.version === "string" ? app.version : undefined;
package/dist/rpc.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { treeRpc, type Row, type Stopped, type Summary, type Tree, type TreeRpc, type Wait } from "./tree/rpc.js";
2
+ export { personRpc, type Answered, type Asks, type PersonRpc } from "./person/rpc.js";
package/dist/rpc.js ADDED
@@ -0,0 +1,3 @@
1
+ // The package's `rpc` entrypoint: the courier's RPC contracts, for a client that calls them.
2
+ export { treeRpc } from "./tree/rpc.js";
3
+ export { personRpc } from "./person/rpc.js";
@@ -0,0 +1,6 @@
1
+ import type { TickJob } from "../platform/hub.js";
2
+ export { cancel, cancelScheduled, deliverDue, isScheduled, schedule, scheduledFor } from "./later.js";
3
+ export type { LaterEntry, LaterInput, LaterPorts } from "./later.js";
4
+ export { addSchedulingTools } from "./tools.js";
5
+ /** The scheduler's tick job: due messages, through the first loaded member, whose `later` is every hub-v2 member's LaterPorts. */
6
+ export declare const dueJob: TickJob;
@@ -0,0 +1,5 @@
1
+ import { deliverDue } from "./later.js";
2
+ export { cancel, cancelScheduled, deliverDue, isScheduled, schedule, scheduledFor } from "./later.js";
3
+ export { addSchedulingTools } from "./tools.js";
4
+ /** The scheduler's tick job: due messages, through the first loaded member, whose `later` is every hub-v2 member's LaterPorts. */
5
+ export const dueJob = (owner, claimed) => deliverDue(owner.later, claimed);
@@ -1,8 +1,6 @@
1
1
  import type { Plugin } from "@opencode/plugin";
2
- import { type Storage } from "./storage.js";
2
+ import { type Storage } from "../platform/storage.js";
3
3
  type Context = Plugin.Context;
4
- /** How often the hub's scheduler looks for due messages; a delivery can be this late. */
5
- export declare const TICK_MS = 15000;
6
4
  export interface LaterEntry {
7
5
  readonly id: string;
8
6
  readonly sessionID: string;
@@ -13,7 +11,8 @@ export interface LaterEntry {
13
11
  }
14
12
  export interface LaterPorts {
15
13
  readonly storage: Storage;
16
- readonly session: Pick<Context["session"], "synthetic">;
14
+ /** `get` for the release of a join group, which waits for its parent's turn to end. */
15
+ readonly session: Pick<Context["session"], "synthetic" | "get">;
17
16
  readonly now: () => number;
18
17
  readonly newID: () => string;
19
18
  readonly log: (message: string) => void;
@@ -29,12 +28,17 @@ export interface LaterInput {
29
28
  export declare function schedule(ports: LaterPorts, from: string, input: LaterInput): Promise<LaterEntry>;
30
29
  /** Drops a pending message; false when there was none, e.g. it was already delivered. */
31
30
  export declare function cancel(ports: LaterPorts, id: string): Promise<boolean>;
31
+ /** The messages scheduled for a session that have not been delivered yet. */
32
+ export declare function scheduledFor(storage: Pick<Storage, "scan">, sessionID: string): Promise<LaterEntry[]>;
33
+ /** Cancels every message scheduled for a session; the ids of those cancelled. */
34
+ export declare function cancelScheduled(ports: LaterPorts, sessionID: string): Promise<string[]>;
35
+ /** Whether a session waits on a scheduled message of its own. */
36
+ export declare function isScheduled(ports: {
37
+ readonly storage: Pick<Storage, "scan">;
38
+ }, sessionID: string): Promise<boolean>;
32
39
  /**
33
- * Delivers every due message once. OpenCode sets the plugin up once per project location, all in
34
- * one process and over one storage, so the instances share `claimed`: an id is claimed
35
- * synchronously, re-read after the claim (another instance may have just delivered it), delivered,
36
- * and only then removed. A crash between delivery and removal delivers it again after a restart,
37
- * which a check-in survives better than being lost.
40
+ * Delivers every due message once: an id is claimed synchronously in the shared `claimed`, re-read,
41
+ * delivered and only then removed; a crash in between delivers it again after a restart, not never.
38
42
  */
39
43
  export declare function deliverDue(ports: LaterPorts, claimed: Set<string>): Promise<void>;
40
44
  export {};
@@ -1,7 +1,5 @@
1
- import { envelope } from "./notices.js";
2
- import { scanAll } from "./storage.js";
3
- /** How often the hub's scheduler looks for due messages; a delivery can be this late. */
4
- export const TICK_MS = 15_000;
1
+ import { envelope } from "../kernel/protocol.js";
2
+ import { scanAll } from "../platform/storage.js";
5
3
  const PREFIX = "later/";
6
4
  /** The latest time a Date can hold; a message due after it could never be shown or delivered. */
7
5
  const LATEST = 8.64e15;
@@ -60,12 +58,23 @@ export async function cancel(ports, id) {
60
58
  function pending(ports) {
61
59
  return scanAll(ports.storage, PREFIX);
62
60
  }
61
+ /** The messages scheduled for a session that have not been delivered yet. */
62
+ export async function scheduledFor(storage, sessionID) {
63
+ return (await pending({ storage: storage })).filter((entry) => entry.sessionID === sessionID);
64
+ }
65
+ /** Cancels every message scheduled for a session; the ids of those cancelled. */
66
+ export async function cancelScheduled(ports, sessionID) {
67
+ const scheduled = await scheduledFor(ports.storage, sessionID);
68
+ const dropped = await Promise.all(scheduled.map(async (later) => ((await cancel(ports, later.id)) ? later.id : undefined)));
69
+ return dropped.filter((id) => id !== undefined);
70
+ }
71
+ /** Whether a session waits on a scheduled message of its own. */
72
+ export async function isScheduled(ports, sessionID) {
73
+ return (await scheduledFor(ports.storage, sessionID)).length > 0;
74
+ }
63
75
  /**
64
- * Delivers every due message once. OpenCode sets the plugin up once per project location, all in
65
- * one process and over one storage, so the instances share `claimed`: an id is claimed
66
- * synchronously, re-read after the claim (another instance may have just delivered it), delivered,
67
- * and only then removed. A crash between delivery and removal delivers it again after a restart,
68
- * which a check-in survives better than being lost.
76
+ * Delivers every due message once: an id is claimed synchronously in the shared `claimed`, re-read,
77
+ * delivered and only then removed; a crash in between delivers it again after a restart, not never.
69
78
  */
70
79
  export async function deliverDue(ports, claimed) {
71
80
  const now = ports.now();
@@ -0,0 +1,6 @@
1
+ /** `own` when the message goes to the session that scheduled it. */
2
+ export declare function laterText(entry: {
3
+ readonly id: string;
4
+ readonly sessionID: string;
5
+ }, fireAt: string, own: boolean): string;
6
+ export declare const cancelText: (id: string, cancelled: boolean) => string;
@@ -0,0 +1,10 @@
1
+ /** `own` when the message goes to the session that scheduled it. */
2
+ export function laterText(entry, fireAt, own) {
3
+ const next = own
4
+ ? "It arrives when due, after your current turn if one is running, so do not wait for it: once nothing " +
5
+ "else is left to do now, end your turn by replying without calling more tools. If what it checks on " +
6
+ "reports first, cancel it then with courier_cancel."
7
+ : "Cancel it with courier_cancel if it is no longer needed.";
8
+ return `Scheduled ${entry.id} for ${fireAt}, to ${entry.sessionID}. ${next}`;
9
+ }
10
+ export const cancelText = (id, cancelled) => cancelled ? `Cancelled ${id}.` : `Nothing pending under ${id}; it may have been delivered.`;
@@ -0,0 +1,14 @@
1
+ import { Schema } from "effect";
2
+ import { type ToolEditor } from "../kernel/tool.js";
3
+ import { type LaterPorts } from "./later.js";
4
+ export declare const LaterInput: Schema.Struct<{
5
+ readonly message: Schema.String;
6
+ readonly delayMinutes: Schema.optional<Schema.Union<readonly [Schema.Number, Schema.String]>>;
7
+ readonly at: Schema.optional<Schema.String>;
8
+ readonly sessionID: Schema.optional<Schema.String>;
9
+ }>;
10
+ export declare const CancelInput: Schema.Struct<{
11
+ readonly id: Schema.String;
12
+ }>;
13
+ /** Adds `courier_later` and `courier_cancel`. */
14
+ export declare function addSchedulingTools(tools: ToolEditor, later: LaterPorts): void;
@@ -0,0 +1,48 @@
1
+ import { Schema } from "effect";
2
+ import { rethrow } from "../kernel/tool.js";
3
+ import { cancel, schedule } from "./later.js";
4
+ import { cancelText, laterText } from "./text.js";
5
+ export const LaterInput = Schema.Struct({
6
+ message: Schema.String.annotate({ description: "The message to deliver." }),
7
+ delayMinutes: Schema.optional(
8
+ // Some models send the number as a string ("3", "3.0") whatever the schema says, and resend it
9
+ // unchanged after the error, so a string is accepted and schedule reads it as a number.
10
+ Schema.Union([Schema.Number, Schema.String]).annotate({
11
+ description: "Deliver this many minutes from now. Give this or at.",
12
+ })),
13
+ at: Schema.optional(Schema.String.annotate({ description: "Deliver at this ISO 8601 time. Give this or delayMinutes." })),
14
+ sessionID: Schema.optional(Schema.String.annotate({ description: "The session to deliver to; defaults to this one." })),
15
+ });
16
+ export const CancelInput = Schema.Struct({
17
+ id: Schema.String.annotate({ description: "The id courier_later returned." }),
18
+ });
19
+ /** Adds `courier_later` and `courier_cancel`. */
20
+ export function addSchedulingTools(tools, later) {
21
+ tools.add({
22
+ name: "courier_later",
23
+ options: { codemode: false },
24
+ description: "Schedule a message for a session (this one by default), delivered when due and waking it if idle. " +
25
+ "Use it as a safety net when you start sessions: schedule one check-in, end your turn by replying without " +
26
+ "calling more tools, and cancel it with courier_cancel if the child reports first. Survives server restarts; " +
27
+ "may arrive up to ~15 seconds late.",
28
+ input: LaterInput,
29
+ execute: async (input, context) => {
30
+ const entry = await schedule(later, context.sessionID, input).catch(rethrow("courier_later"));
31
+ const fireAt = new Date(entry.fireAt).toISOString();
32
+ return {
33
+ content: laterText(entry, fireAt, entry.sessionID === context.sessionID),
34
+ metadata: { id: entry.id, fireAt, sessionID: entry.sessionID },
35
+ };
36
+ },
37
+ });
38
+ tools.add({
39
+ name: "courier_cancel",
40
+ options: { codemode: false },
41
+ description: "Cancel a message scheduled with courier_later, e.g. because the child it was waiting for reported.",
42
+ input: CancelInput,
43
+ execute: async (input) => {
44
+ const cancelled = await cancel(later, input.id).catch(rethrow("courier_cancel"));
45
+ return { content: cancelText(input.id, cancelled), metadata: { id: input.id, cancelled } };
46
+ },
47
+ });
48
+ }
@@ -0,0 +1,17 @@
1
+ import type { Plugin } from "@opencode/plugin";
2
+ /** Why a model loads the skill, listed with every session's available skills. */
3
+ export declare const SKILL_DESCRIPTION: string;
4
+ /** The skill's text; it names no limits, which the role part and the brief give for the session's own tree. */
5
+ export declare const SKILL_CONTENT: string;
6
+ /** The editor OpenCode hands a skill transform. */
7
+ export type SkillEditor = Parameters<Parameters<Plugin.Context["skill"]["transform"]>[0]>[0];
8
+ /** The courier-orchestrate skill as OpenCode lists it: its id, name and description, and the text the model loads. */
9
+ export declare const orchestrateSkill: () => {
10
+ readonly id: "courier-orchestrate";
11
+ readonly name: "courier-orchestrate";
12
+ readonly description: string;
13
+ readonly path: "/opencode-courier/courier-orchestrate.md";
14
+ readonly content: string;
15
+ };
16
+ /** Adds the courier-orchestrate skill; every instance adds the same one, under the same id. */
17
+ export declare function addSkill(skills: SkillEditor): void;
package/dist/skill.js ADDED
@@ -0,0 +1,62 @@
1
+ import { SKILL_ID, SPLIT_LINE } from "./kernel/protocol.js";
2
+ // The courier-orchestrate skill: the playbook a session loads with its skill tool.
3
+ /** Why a model loads the skill, listed with every session's available skills. */
4
+ export const SKILL_DESCRIPTION = "Load at the start of any implementation task that may have independent parts, or when a session started you, to " +
5
+ "decide whether to split it across OpenCode sessions with courier_spawn and state the decision, and how: plan the " +
6
+ "parts, start them, end your turn, read the reports, verify the work, integrate it and report upward, and what to do " +
7
+ "when a part goes wrong.";
8
+ /** The skill's text; it names no limits, which the role part and the brief give for the session's own tree. */
9
+ export const SKILL_CONTENT = [
10
+ "How to split a task across OpenCode sessions with opencode-courier, and bring the results back up. Your brief, or the " +
11
+ "opencode-courier role line in your system prompt, names your depth in the session tree and its limits.",
12
+ "",
13
+ "1. Decide: split or do. Split when the task has 2 or more independent, substantial parts touching separate files or " +
14
+ "areas; do it yourself when it is small, sequential or tightly coupled. Never start exactly one session. " +
15
+ "An implementation task with one tightly coupled core usually still has independent parts: its e2e scenario, its " +
16
+ "documentation, sometimes a second test file, and a review of the core by a session with fresh context, which reads the " +
17
+ "committed diff against the task and the repository's rules and reports its findings (isolate: false, it edits nothing). " +
18
+ "Weigh each of them: do the core yourself first and commit it, so that the names and texts the other parts depend on are " +
19
+ "fixed, then, if 2 or more remain, start one session per remaining part, in one group. A part that is itself big and " +
20
+ "divisible is split again by the session that gets it; at the deepest level a session does its part itself. " +
21
+ `Say which in one line of your reply, before you start: ${SPLIT_LINE}, so that a reasoned decision to do it ` +
22
+ "yourself can be told from one never made.",
23
+ "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 " +
24
+ "produce and where, how to check it, what to report. Say if it may split its part further.",
25
+ "3. Start them: one courier_spawn per part, all with the same group name, so you are woken once with every report; " +
26
+ "isolate: true for each part that edits files in parallel with another (commit your own work first if you are isolated " +
27
+ "yourself). For long work, courier_later a check-in for yourself, and courier_cancel it when the reports arrive.",
28
+ "4. End your turn: reply without calling more tools. Do not poll with courier_status, courier_children, courier_tree or " +
29
+ "sleep. The group's reports, a blocked report, a question, a permission request, a failed turn or a silent end each start " +
30
+ "a new turn for you.",
31
+ "5. Read each report by its status and its artifacts (branch, commits, files, checks). done: verify it. partial: what is " +
32
+ "left and why; have the session finish it with a courier_send, or do it yourself. blocked: make the decision it asks for " +
33
+ "and send it with courier_send; if only the person can make it, ask them with your question tool and pass the answer on. " +
34
+ "failed: read the error; message the session to try again, start a replacement, or carry on without it.",
35
+ "6. Verify first-hand: open the files, run the checks. Never hand the checking to another session: a review session's " +
36
+ "findings are a report to act on, and running the checks is still yours. Run checks through " +
37
+ "the project's scripts (bun run <script>, npm run <script>), not ./node_modules/.bin/<tool>, so that one permission " +
38
+ "rule covers them: your own permission prompts are relayed nowhere, and each one stalls the whole tree.",
39
+ "7. Integrate: merge each reported branch into your own checkout (git merge courier/<session>), resolve conflicts " +
40
+ "yourself, run the checks once more, then courier_cleanup the isolated sessions you are done with.",
41
+ "8. Report upward if a session started you: one courier_send to it, with one status for the whole (done only when every " +
42
+ "part is verified and integrated; partial, blocked or failed otherwise) and the artifacts. At the top, reply to the person.",
43
+ "",
44
+ "When a part goes wrong:",
45
+ "- A failed turn (failed=\"...\") or a silent end (ended=\"without-report\", quoting the session's last reply): message " +
46
+ "the session with courier_send to carry on, to report, or to try again; or start a replacement. Until it reports, it " +
47
+ "counts toward the limits.",
48
+ "- A refusal from courier_spawn naming maxDepth, maxChildren or maxTotal: do that part yourself, or end your turn and " +
49
+ "start it once a report has arrived. Do not retry it in a loop.",
50
+ "- Lost track, after a compaction or a restart: one courier_tree shows every session under you with its depth, its " +
51
+ "parent and whether it still owes a report; one look, not a loop.",
52
+ "- A subtree that went wrong or is no longer needed: courier_stop its top session; everything under it is stopped too, " +
53
+ "and nothing in it reports back. cleanup: true removes their worktrees.",
54
+ ].join("\n");
55
+ /** A skill's path is where OpenCode would look for its files; this one has none, so a path of the plugin's own. */
56
+ const SKILL_PATH = `/opencode-courier/${SKILL_ID}.md`;
57
+ /** The courier-orchestrate skill as OpenCode lists it: its id, name and description, and the text the model loads. */
58
+ export const orchestrateSkill = () => ({ id: SKILL_ID, name: SKILL_ID, description: SKILL_DESCRIPTION, path: SKILL_PATH, content: SKILL_CONTENT });
59
+ /** Adds the courier-orchestrate skill; every instance adds the same one, under the same id. */
60
+ export function addSkill(skills) {
61
+ skills.add(orchestrateSkill());
62
+ }
@@ -1,25 +1,4 @@
1
- import type { Plugin } from "@opencode/plugin";
2
- import { type CleanupResult } from "./notices.js";
3
- import { type RosterStorage } from "./roster.js";
4
- type Context = Plugin.Context;
5
- /** What would be lost by removing a worktree. */
6
- export interface WorktreeState {
7
- /** Paths with uncommitted changes, untracked files included, as `git status --porcelain` lists them. */
8
- readonly changes: readonly string[];
9
- /**
10
- * Commits reachable from the worktree's HEAD but from no branch, tag or remote-tracking ref, nor
11
- * from the commit the worktree was made from, newest first.
12
- */
13
- readonly commits: readonly string[];
14
- }
15
- export interface CleanupPorts {
16
- readonly storage: RosterStorage;
17
- readonly worktree: Pick<Context["worktree"], "remove">;
18
- /** The project of the plugin's location, for roster entries recorded before they carried their project. */
19
- readonly projectID: string;
20
- /** The worktree's state, or undefined when its directory no longer exists; `base` is the commit it was made from. */
21
- readonly inspect: (directory: string, base?: string) => Promise<WorktreeState | undefined>;
22
- }
1
+ import type { CleanupPorts, CleanupResult, WorktreeState } from "./ports.js";
23
2
  export interface CleanupInput {
24
3
  readonly sessionID: string;
25
4
  readonly force?: boolean;
@@ -27,9 +6,8 @@ export interface CleanupInput {
27
6
  /** At most this many changes and commits are returned; the reason still counts them all. */
28
7
  export declare const MAX_LISTED = 50;
29
8
  /**
30
- * Removes the worktree of an isolated child the parent started, and forgets the child. A worktree
31
- * with uncommitted changes or commits that exist nowhere else is kept unless `force` is set, and the
32
- * result says what is in it.
9
+ * Forgets a child the parent started, removing its worktree if it is isolated. A worktree with uncommitted changes
10
+ * or commits found nowhere else is kept unless `force` is set; the result says so.
33
11
  */
34
12
  export declare function cleanup(ports: CleanupPorts, parentID: string, input: CleanupInput): Promise<CleanupResult>;
35
13
  /** Names the git to run, by absolute path, overriding the places `GIT_LOCATIONS` lists. */
@@ -46,9 +24,10 @@ export declare const GIT_LOCATIONS: Readonly<Record<"posix" | "win32", readonly
46
24
  export declare function findGit(env?: Readonly<Record<string, string | undefined>>, platform?: NodeJS.Platform, exists?: (path: string) => boolean): string;
47
25
  /** The commit a worktree is on, or undefined when git cannot tell. */
48
26
  export declare function headOf(directory: string): Promise<string | undefined>;
27
+ /** Whether a worktree has uncommitted changes, untracked files included; undefined when git cannot tell. */
28
+ export declare function dirtyOf(directory: string): Promise<boolean | undefined>;
49
29
  /**
50
30
  * Reads a worktree's state with git; undefined when the directory is gone. Commits reachable from
51
31
  * `base`, the commit the worktree was made from, are not its own work and are not listed.
52
32
  */
53
33
  export declare function inspectWorktree(directory: string, base?: string): Promise<WorktreeState | undefined>;
54
- export {};
@@ -1,32 +1,34 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { isAbsolute } from "node:path";
4
- import { keepReason } from "./notices.js";
5
- import { remove, rosterKey } from "./roster.js";
4
+ import { forgetDropped } from "./forget.js";
5
+ import { childStore } from "./store.js";
6
+ import { keepReason } from "./text.js";
6
7
  /** At most this many changes and commits are returned; the reason still counts them all. */
7
8
  export const MAX_LISTED = 50;
8
9
  /**
9
- * Removes the worktree of an isolated child the parent started, and forgets the child. A worktree
10
- * with uncommitted changes or commits that exist nowhere else is kept unless `force` is set, and the
11
- * result says what is in it.
10
+ * Forgets a child the parent started, removing its worktree if it is isolated. A worktree with uncommitted changes
11
+ * or commits found nowhere else is kept unless `force` is set; the result says so.
12
12
  */
13
13
  export async function cleanup(ports, parentID, input) {
14
- const entry = (await ports.storage.get(rosterKey(parentID, input.sessionID)));
14
+ const entry = await childStore(ports.storage).entry(parentID, input.sessionID);
15
15
  if (!entry)
16
16
  throw new Error(`${input.sessionID} is not on the courier_children list of ${parentID}.`);
17
- if (!entry.isolated)
18
- throw new Error(`${input.sessionID} ran in ${entry.directory}, not in a worktree of its own; there is nothing to remove.`);
19
17
  const { directory } = entry;
20
- const forget = () => remove(ports.storage, parentID, input.sessionID);
18
+ // A member still out is dropped from its group, so the group is released without it; the result names that group.
19
+ const forget = async () => {
20
+ const group = await forgetDropped(ports, entry);
21
+ return group ? { group } : {};
22
+ };
23
+ if (!entry.isolated)
24
+ return { sessionID: input.sessionID, directory, outcome: "forgotten", ...(await forget()) };
21
25
  // With force the state only decides whether there is anything left to remove, so a worktree git
22
26
  // can no longer read is still removed.
23
27
  const state = await ports
24
28
  .inspect(directory, entry.base)
25
29
  .catch((error) => (input.force ? { changes: [], commits: [] } : Promise.reject(error)));
26
- if (!state) {
27
- await forget();
28
- return { sessionID: input.sessionID, directory, outcome: "gone" };
29
- }
30
+ if (!state)
31
+ return { sessionID: input.sessionID, directory, outcome: "gone", ...(await forget()) };
30
32
  const reason = keepReason(state);
31
33
  if (reason && !input.force)
32
34
  return {
@@ -42,8 +44,7 @@ export async function cleanup(ports, parentID, input) {
42
44
  directory,
43
45
  force: input.force === true,
44
46
  });
45
- await forget();
46
- return { sessionID: input.sessionID, directory, outcome: "removed" };
47
+ return { sessionID: input.sessionID, directory, outcome: "removed", ...(await forget()) };
47
48
  }
48
49
  /** Names the git to run, by absolute path, overriding the places `GIT_LOCATIONS` lists. */
49
50
  export const GIT_ENV = "OPENCODE_COURIER_GIT";
@@ -85,6 +86,10 @@ function git(directory, args) {
85
86
  export function headOf(directory) {
86
87
  return git(directory, ["rev-parse", "HEAD"]).then((out) => out.trim() || undefined, () => undefined);
87
88
  }
89
+ /** Whether a worktree has uncommitted changes, untracked files included; undefined when git cannot tell. */
90
+ export function dirtyOf(directory) {
91
+ return git(directory, ["status", "--porcelain"]).then((out) => out.trim().length > 0, () => undefined);
92
+ }
88
93
  /**
89
94
  * Reads a worktree's state with git; undefined when the directory is gone. Commits reachable from
90
95
  * `base`, the commit the worktree was made from, are not its own work and are not listed.
@@ -0,0 +1,15 @@
1
+ import type { ContextPorts } from "./ports.js";
2
+ /** The part of a session context this hook changes. */
3
+ export interface Shaped {
4
+ readonly sessionID: string;
5
+ tools: Record<string, unknown>;
6
+ system: Array<{
7
+ type: "text";
8
+ text: string;
9
+ }>;
10
+ }
11
+ /**
12
+ * The `context` hook: names a session's role in its tree in a system part, and hides courier_spawn
13
+ * from one at `maxDepth`. A session nobody spawned that has started none is pointed to the skill, if it could spawn.
14
+ */
15
+ export declare function shapeContext(ports: ContextPorts, event: Shaped): Promise<void>;
@@ -0,0 +1,34 @@
1
+ import { setBounded } from "../platform/bounded.js";
2
+ import { childStore } from "./store.js";
3
+ import { ROLE_PREFIX, rolePart } from "./text.js";
4
+ /** How many sessions' depths an instance remembers. */
5
+ const ROLES_MAX = 1000;
6
+ /**
7
+ * The `context` hook: names a session's role in its tree in a system part, and hides courier_spawn
8
+ * from one at `maxDepth`. A session nobody spawned that has started none is pointed to the skill, if it could spawn.
9
+ */
10
+ export async function shapeContext(ports, event) {
11
+ try {
12
+ const depth = await depthOf(ports, event.sessionID);
13
+ if (depth === null && !event.tools.courier_spawn)
14
+ return;
15
+ if (depth !== null && depth >= ports.limits.maxDepth)
16
+ delete event.tools.courier_spawn;
17
+ if (!event.system.some((part) => part.text.startsWith(ROLE_PREFIX)))
18
+ event.system.push({ type: "text", text: rolePart(depth, ports.limits) });
19
+ }
20
+ catch (error) {
21
+ ports.log(`courier: cannot tell the role of session ${event.sessionID}: ${String(error)}`);
22
+ }
23
+ }
24
+ async function depthOf(ports, sessionID) {
25
+ const known = ports.roles.get(sessionID);
26
+ if (known !== undefined)
27
+ return known;
28
+ // The reverse index alone: a scan here would run for every session's first request, courier or not.
29
+ const children = childStore(ports.storage);
30
+ const chain = await children.indexedLineage(sessionID);
31
+ const depth = chain.length || ((await children.children(sessionID)).length ? 0 : null);
32
+ setBounded(ports.roles, sessionID, depth, ROLES_MAX);
33
+ return depth;
34
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The tree's pure rules, on plain data: the limits, a session's lineage, whether it runs, and a group
3
+ * member's standing. Imports nothing of the plugin's but `kernel/` and `platform/json.ts`.
4
+ */
5
+ import type { Plugin } from "@opencode/plugin";
6
+ import type { Status } from "../kernel/protocol.js";
7
+ type Context = Plugin.Context;
8
+ /** The limits on the tree of sessions courier_spawn builds, from the plugin's options. */
9
+ export interface Limits {
10
+ readonly maxDepth: number;
11
+ readonly maxChildren: number;
12
+ readonly maxTotal: number;
13
+ }
14
+ export declare const DEFAULT_LIMITS: Limits;
15
+ /** The limits in the plugin's options; one that is not a positive integer keeps its default and is named in `problems`. */
16
+ export declare function readLimits(options: unknown): {
17
+ limits: Limits;
18
+ problems: string[];
19
+ };
20
+ /** The `nativeChildren` option: whether a child started without isolate gets OpenCode's own `parentID`; off unless `true`. */
21
+ export declare function readNativeChildren(options: unknown): {
22
+ nativeChildren: boolean;
23
+ problems: string[];
24
+ };
25
+ /** A session's times, as `session.get` gives them. */
26
+ type Times = Awaited<ReturnType<Context["session"]["get"]>>["time"];
27
+ /** Whether a session with these times runs: it has never finished a turn, or something reached it after the last one ended. */
28
+ export declare const busy: (time: Pick<Times, "updated" | "idle">) => boolean;
29
+ /** A session started with courier_spawn, recorded under the session that started it. */
30
+ export interface RosterEntry {
31
+ readonly sessionID: string;
32
+ readonly parentID: string;
33
+ readonly title: string;
34
+ readonly directory: string;
35
+ readonly isolated: boolean;
36
+ readonly createdAt: number;
37
+ /** For an isolated child, the directory its worktree was made from; for the record, nothing acts on it. */
38
+ readonly source?: string;
39
+ /** For an isolated child, the project its worktree belongs to; courier_cleanup removes it from there. */
40
+ readonly project?: string;
41
+ /** For an isolated child, the commit its worktree was made from; its own work is what came after. */
42
+ readonly base?: string;
43
+ /** `"status"` when its brief asked for a report with a status; absent for a child an earlier release briefed. */
44
+ readonly reports?: "status";
45
+ /** The join group courier_spawn put it in, whose release holds its report; absent for a child in none. */
46
+ readonly group?: string;
47
+ /** Created with OpenCode's own `parentID` (the `nativeChildren` option), so OpenCode shows its asks in its parent's view. */
48
+ readonly native?: true;
49
+ }
50
+ /** `lineage` over roster entries already read. */
51
+ export declare function lineageIn(entries: ReadonlyArray<RosterEntry>, sessionID: string): RosterEntry[];
52
+ /** Roster entries by their session, for `lineageBy`. */
53
+ export declare function bySession(entries: ReadonlyArray<RosterEntry>): Map<string, RosterEntry>;
54
+ /** `lineage` over roster entries already read and mapped by `bySession`. */
55
+ export declare function lineageBy(bySession: ReadonlyMap<string, RosterEntry>, sessionID: string): RosterEntry[];
56
+ /**
57
+ * The session whose view OpenCode shows the asks of a lineage's first session in: up its native parents, to the
58
+ * first session that is not a native child (an isolated one, or the top). The session itself when it is not native.
59
+ */
60
+ export declare function nativeRootOf(chain: ReadonlyArray<RosterEntry>): string;
61
+ /** What a report lists besides its text; `checks` are the commands the session ran and what each gave. */
62
+ export interface Artifacts {
63
+ readonly branch?: string;
64
+ readonly commits?: ReadonlyArray<string>;
65
+ readonly files?: ReadonlyArray<string>;
66
+ readonly checks?: ReadonlyArray<{
67
+ readonly command: string;
68
+ readonly result: string;
69
+ }>;
70
+ }
71
+ /** A report held with its group: its status, text and artifacts, as courier_send was given them, and when. */
72
+ export interface HeldReport {
73
+ readonly at: number;
74
+ readonly status: Status;
75
+ readonly message: string;
76
+ readonly artifacts?: Artifacts;
77
+ }
78
+ /** A member of a group that left it without a report, and why: its turn failed or was interrupted, it was deleted, or courier_cleanup dropped it. */
79
+ export interface LeftMember {
80
+ readonly sessionID: string;
81
+ readonly title: string;
82
+ readonly by: "failed" | "interrupted" | "deleted" | "dropped";
83
+ }
84
+ export declare const isGroupName: (value: unknown) => value is string;
85
+ /** A member of a group, as stored: its title for the release notice, and its report or how it left. */
86
+ export interface Member {
87
+ readonly title: string;
88
+ readonly joinedAt: number;
89
+ readonly report?: HeldReport;
90
+ readonly left?: {
91
+ readonly at: number;
92
+ readonly by: LeftMember["by"];
93
+ };
94
+ }
95
+ /** What is known of a member's standing: its report is held, still out, or it left, or it is no longer in an open group. */
96
+ export type Standing = "held" | "out" | LeftMember["by"] | "released";
97
+ /** A member's standing from its stored value, or `released` when there is none. */
98
+ export declare function standingOf(member: Member | undefined): Standing;
99
+ /** Whether every member of a group has a held report or has left: released once the parent's turn has ended. */
100
+ export declare const complete: (members: ReadonlyArray<Member>) => boolean;
101
+ export {};