opencode-courier 0.2.2 → 0.3.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 (60) hide show
  1. package/README.md +247 -108
  2. package/dist/bounded.d.ts +8 -0
  3. package/dist/bounded.js +19 -0
  4. package/dist/cleanup.d.ts +21 -21
  5. package/dist/cleanup.js +61 -30
  6. package/dist/courier.d.ts +116 -15
  7. package/dist/courier.js +239 -74
  8. package/dist/group.d.ts +86 -0
  9. package/dist/group.js +247 -0
  10. package/dist/hub.d.ts +300 -0
  11. package/dist/hub.js +343 -0
  12. package/dist/index.d.ts +5 -13
  13. package/dist/index.js +82 -313
  14. package/dist/json.d.ts +8 -0
  15. package/dist/json.js +15 -0
  16. package/dist/later.d.ts +7 -7
  17. package/dist/later.js +8 -7
  18. package/dist/limits.d.ts +65 -0
  19. package/dist/limits.js +109 -0
  20. package/dist/notices.d.ts +356 -0
  21. package/dist/notices.js +678 -0
  22. package/dist/question/answer.d.ts +35 -0
  23. package/dist/question/answer.js +214 -0
  24. package/dist/question/index.d.ts +6 -0
  25. package/dist/question/index.js +7 -0
  26. package/dist/question/lifecycle.d.ts +43 -0
  27. package/dist/question/lifecycle.js +91 -0
  28. package/dist/question/pure.d.ts +48 -0
  29. package/dist/question/pure.js +74 -0
  30. package/dist/question/relay.d.ts +5 -0
  31. package/dist/question/relay.js +269 -0
  32. package/dist/question/runtime.d.ts +20 -0
  33. package/dist/question/runtime.js +74 -0
  34. package/dist/question/shared.d.ts +24 -0
  35. package/dist/question/shared.js +24 -0
  36. package/dist/relay.d.ts +32 -36
  37. package/dist/relay.js +6 -56
  38. package/dist/report.d.ts +48 -0
  39. package/dist/report.js +88 -0
  40. package/dist/roster.d.ts +48 -8
  41. package/dist/roster.js +140 -17
  42. package/dist/skill.d.ts +13 -0
  43. package/dist/skill.js +9 -0
  44. package/dist/stop.d.ts +41 -0
  45. package/dist/stop.js +120 -0
  46. package/dist/storage.d.ts +8 -1
  47. package/dist/storage.js +8 -5
  48. package/dist/tools.d.ts +94 -0
  49. package/dist/tools.js +297 -0
  50. package/dist/tree.d.ts +102 -0
  51. package/dist/tree.js +58 -0
  52. package/dist/version.d.ts +4 -10
  53. package/dist/version.js +4 -10
  54. package/dist/watch.d.ts +70 -42
  55. package/dist/watch.js +346 -51
  56. package/dist/webhook.d.ts +11 -28
  57. package/dist/webhook.js +46 -146
  58. package/package.json +16 -5
  59. package/dist/question.d.ts +0 -158
  60. package/dist/question.js +0 -768
package/dist/cleanup.js CHANGED
@@ -1,46 +1,30 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
- import { rosterKey } from "./roster.js";
4
- /** Why a worktree in this state must be kept, or undefined when removing it loses nothing. */
5
- export function keepReason(state) {
6
- const reasons = [
7
- state.changes.length ? `${count(state.changes.length, "uncommitted change")} (${preview(state.changes)})` : "",
8
- state.commits.length
9
- ? `${count(state.commits.length, "commit")} on no branch, tag or remote (${preview(state.commits)})`
10
- : "",
11
- ].filter(Boolean);
12
- return reasons.length ? reasons.join(" and ") : undefined;
13
- }
14
- function count(n, noun) {
15
- return `${n} ${noun}${n === 1 ? "" : "s"}`;
16
- }
3
+ import { isAbsolute } from "node:path";
4
+ import { markLeft, membershipOf } from "./group.js";
5
+ import { keepReason } from "./notices.js";
6
+ import { remove, rosterKey } from "./roster.js";
17
7
  /** At most this many changes and commits are returned; the reason still counts them all. */
18
8
  export const MAX_LISTED = 50;
19
- function preview(items) {
20
- return items.length > 5 ? `${items.slice(0, 5).join(", ")}, ...` : items.join(", ");
21
- }
22
9
  /**
23
- * Removes the worktree of an isolated child the parent started, and forgets the child. A worktree
24
- * with uncommitted changes or commits that exist nowhere else is kept unless `force` is set, and the
25
- * 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.
26
12
  */
27
13
  export async function cleanup(ports, parentID, input) {
28
14
  const entry = (await ports.storage.get(rosterKey(parentID, input.sessionID)));
29
15
  if (!entry)
30
16
  throw new Error(`${input.sessionID} is not on the courier_children list of ${parentID}.`);
31
- if (!entry.isolated)
32
- throw new Error(`${input.sessionID} ran in ${entry.directory}, not in a worktree of its own; there is nothing to remove.`);
33
17
  const { directory } = entry;
34
- const forget = () => ports.storage.remove(rosterKey(parentID, input.sessionID));
18
+ const forget = () => forgetChild(ports, entry);
19
+ if (!entry.isolated)
20
+ return { sessionID: input.sessionID, directory, outcome: "forgotten", ...(await forget()) };
35
21
  // With force the state only decides whether there is anything left to remove, so a worktree git
36
22
  // can no longer read is still removed.
37
23
  const state = await ports
38
24
  .inspect(directory, entry.base)
39
25
  .catch((error) => (input.force ? { changes: [], commits: [] } : Promise.reject(error)));
40
- if (!state) {
41
- await forget();
42
- return { sessionID: input.sessionID, directory, outcome: "gone" };
43
- }
26
+ if (!state)
27
+ return { sessionID: input.sessionID, directory, outcome: "gone", ...(await forget()) };
44
28
  const reason = keepReason(state);
45
29
  if (reason && !input.force)
46
30
  return {
@@ -56,16 +40,63 @@ export async function cleanup(ports, parentID, input) {
56
40
  directory,
57
41
  force: input.force === true,
58
42
  });
59
- await forget();
60
- return { sessionID: input.sessionID, directory, outcome: "removed" };
43
+ return { sessionID: input.sessionID, directory, outcome: "removed", ...(await forget()) };
44
+ }
45
+ /**
46
+ * Drops a member still out from its group as `dropped`, so the group is released without it, then the roster entry
47
+ * and report state. A held report stays with its group. Returns the group it was dropped from.
48
+ */
49
+ async function forgetChild(ports, entry) {
50
+ const member = entry.group ? await membershipOf(ports.storage, entry.parentID, entry.group, entry.sessionID) : undefined;
51
+ if (member?.out && (await markLeft(ports.storage, member, "dropped", ports.now())))
52
+ ports.nudge();
53
+ await remove(ports.storage, entry.parentID, entry.sessionID);
54
+ return member?.out ? { group: member.group } : {};
55
+ }
56
+ /** Names the git to run, by absolute path, overriding the places `GIT_LOCATIONS` lists. */
57
+ export const GIT_ENV = "OPENCODE_COURIER_GIT";
58
+ /**
59
+ * Where git is looked for, in order: its usual install locations, never `PATH`, so a writable
60
+ * directory early in `PATH` cannot put another program in its place.
61
+ */
62
+ export const GIT_LOCATIONS = {
63
+ posix: [
64
+ "/usr/bin/git",
65
+ "/usr/local/bin/git",
66
+ "/opt/homebrew/bin/git",
67
+ // NixOS, and nix-darwin.
68
+ "/run/current-system/sw/bin/git",
69
+ ],
70
+ win32: [String.raw `C:\Program Files\Git\cmd\git.exe`, String.raw `C:\Program Files (x86)\Git\cmd\git.exe`],
71
+ };
72
+ /**
73
+ * The git to run: `OPENCODE_COURIER_GIT` when it is an absolute path, otherwise the first of
74
+ * `GIT_LOCATIONS` that exists. Throws when there is none.
75
+ */
76
+ export function findGit(env = process.env, platform = process.platform, exists = existsSync) {
77
+ const named = env[GIT_ENV]?.trim();
78
+ if (named) {
79
+ if (!isAbsolute(named))
80
+ throw new Error(`${GIT_ENV} must be an absolute path, not ${named}.`);
81
+ return named;
82
+ }
83
+ const locations = GIT_LOCATIONS[platform === "win32" ? "win32" : "posix"];
84
+ const found = locations.find((path) => exists(path));
85
+ if (!found)
86
+ throw new Error(`git is in none of ${locations.join(", ")}; set ${GIT_ENV} to its absolute path.`);
87
+ return found;
61
88
  }
62
89
  function git(directory, args) {
63
- return new Promise((resolve, reject) => execFile("git", ["-C", directory, ...args], { maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => error ? reject(new Error(`git ${args[0]} in ${directory}: ${stderr.trim() || error.message}`)) : resolve(stdout)));
90
+ return new Promise((resolve, reject) => execFile(findGit(), ["-C", directory, ...args], { maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => error ? reject(new Error(`git ${args[0]} in ${directory}: ${stderr.trim() || error.message}`)) : resolve(stdout)));
64
91
  }
65
92
  /** The commit a worktree is on, or undefined when git cannot tell. */
66
93
  export function headOf(directory) {
67
94
  return git(directory, ["rev-parse", "HEAD"]).then((out) => out.trim() || undefined, () => undefined);
68
95
  }
96
+ /** Whether a worktree has uncommitted changes, untracked files included; undefined when git cannot tell. */
97
+ export function dirtyOf(directory) {
98
+ return git(directory, ["status", "--porcelain"]).then((out) => out.trim().length > 0, () => undefined);
99
+ }
69
100
  /**
70
101
  * Reads a worktree's state with git; undefined when the directory is gone. Commits reachable from
71
102
  * `base`, the commit the worktree was made from, are not its own work and are not listed.
package/dist/courier.d.ts CHANGED
@@ -1,21 +1,31 @@
1
1
  import type { Plugin } from "@opencode/plugin";
2
- import type { Prompt } from "./question.js";
3
- import { type RosterStorage } from "./roster.js";
2
+ import { type Limits, type SpawnGate } from "./limits.js";
3
+ import { type Artifacts, type Prompt, type Sent, type Status } from "./notices.js";
4
+ import { type RosterEntry, type RosterStorage } from "./roster.js";
4
5
  type Context = Plugin.Context;
5
6
  /** The slice of the plugin context the courier tools use; tests pass a fake. */
6
7
  export interface CourierPorts {
7
- readonly session: Pick<Context["session"], "create" | "prompt" | "synthetic" | "get" | "context">;
8
+ readonly session: Pick<Context["session"], "create" | "prompt" | "synthetic" | "get" | "context" | "interrupt">;
8
9
  readonly agent: Pick<Context["agent"], "get">;
9
10
  readonly worktree: Pick<Context["worktree"], "create" | "remove">;
10
11
  /** The project of the plugin's location; an isolated child's worktree is made in it, and removed from it. */
11
12
  readonly projectID: string;
12
13
  /** The commit a directory's checkout is on, or undefined; recorded as an isolated child's base. */
13
14
  readonly head: (directory: string) => Promise<string | undefined>;
15
+ /** Whether a directory's checkout has uncommitted changes, or undefined when git cannot tell. */
16
+ readonly dirty: (directory: string) => Promise<boolean | undefined>;
14
17
  readonly storage: RosterStorage;
15
18
  readonly directory: string;
16
19
  readonly now: () => number;
17
20
  /** The permission requests and questions a session waits on, wherever they are pending. */
18
21
  readonly pending: (sessionID: string) => Promise<ReadonlyArray<Pending>>;
22
+ readonly limits: Limits;
23
+ readonly gate: SpawnGate;
24
+ /** The depths the `context` hook remembers; a parent's is dropped when it starts a child. */
25
+ readonly roles: Map<string, number | null>;
26
+ /** Has the scheduler deliver what is due now rather than at its next tick: a group a report has just completed. */
27
+ readonly nudge: () => void;
28
+ readonly log: (message: string) => void;
19
29
  }
20
30
  /** A request a session waits on until someone answers it: a permission request, or a question it asked. */
21
31
  export type Pending = {
@@ -36,10 +46,13 @@ export interface SpawnInput {
36
46
  readonly title?: string;
37
47
  readonly agent?: string;
38
48
  readonly isolate?: boolean;
49
+ readonly group?: string;
39
50
  }
40
51
  export interface SendInput {
41
52
  readonly sessionID: string;
42
53
  readonly message: string;
54
+ readonly status?: Status;
55
+ readonly artifacts?: Artifacts;
43
56
  readonly queue?: boolean;
44
57
  }
45
58
  export interface StatusInput {
@@ -48,52 +61,140 @@ export interface StatusInput {
48
61
  export interface ChildrenInput {
49
62
  readonly sessionID?: string;
50
63
  }
51
- /** Closes a tool result after which the caller most likely has nothing left to do. */
52
- export declare const END_TURN = "If nothing else is left to do now, end your turn by replying without calling more tools.";
53
- export declare function childBrief(parentID: string, task: string): string;
54
- export declare function envelope(from: string, message: string, attributes?: Record<string, string>): string;
55
- /** Creates a child session, hands it the task and returns at once; the child reports back with courier_send. */
64
+ /**
65
+ * Creates a child session, hands it the task and returns at once; the child reports back with
66
+ * courier_send. Refused past a limit, whether or not the `context` hook hid the tool.
67
+ */
56
68
  export declare function spawn(ports: CourierPorts, parentID: string, input: SpawnInput): Promise<{
69
+ uncommitted?: boolean | undefined;
70
+ fromParent?: boolean | undefined;
71
+ groupError?: string | undefined;
57
72
  rosterError?: string | undefined;
73
+ group?: string | undefined;
58
74
  sessionID: string;
59
75
  directory: string;
60
76
  }>;
61
- /** Drops a message into another session's inbox; OpenCode wakes that session if it is idle. */
62
- export declare function send(ports: CourierPorts, from: string, input: SendInput): Promise<{
63
- messageID: string;
64
- }>;
77
+ /**
78
+ * Drops a message into another session's inbox; OpenCode wakes that session if it is idle. From a spawned session
79
+ * to the one that started it, it is its report or its progress (`noteReport`); a group member's final report is held (`hold`).
80
+ */
81
+ export declare function send(ports: CourierPorts, from: string, input: SendInput): Promise<Sent>;
65
82
  /** A one-off look at a session, for check-ins; not meant to be called in a loop. */
66
83
  export declare function status(ports: CourierPorts, input: StatusInput): Promise<Partial<{
67
84
  sessionID: string;
68
85
  title: string | undefined;
69
86
  parentID: string | undefined;
70
- outcome: "failed" | "succeeded" | "interrupted" | undefined;
87
+ outcome: "failed" | "interrupted" | "succeeded" | undefined;
71
88
  updated: number;
72
89
  idle: number | undefined;
73
90
  lastText: string | undefined;
74
91
  pending: readonly Pending[] | undefined;
75
92
  }>>;
76
- /** The sessions a parent started, each with what courier_status reports, or the error it gave. */
93
+ /** The text of a session's last reply, from its context's messages, or undefined when it has none. */
94
+ export declare function lastReply(messages: Awaited<ReturnType<Context["session"]["context"]>>): string | undefined;
95
+ /**
96
+ * The sessions a parent started, each with what courier_status reports, or the error it gave; a child in a
97
+ * group with the group's name and its standing there: `held`, `out`, how it left, `released` or `unknown`.
98
+ */
77
99
  export declare function listChildren(ports: CourierPorts, parentID: string): Promise<({
100
+ group: {
101
+ name: string;
102
+ report: string;
103
+ };
104
+ directory: string;
105
+ isolated: boolean;
106
+ created: number;
107
+ sessionID?: string | undefined;
108
+ title?: string | undefined;
109
+ parentID?: string | undefined;
110
+ outcome?: "failed" | "interrupted" | "succeeded" | undefined;
111
+ updated?: number | undefined;
112
+ idle?: number | undefined;
113
+ lastText?: string | undefined;
114
+ pending?: readonly Pending[] | undefined;
115
+ } | {
116
+ group?: undefined;
78
117
  directory: string;
79
118
  isolated: boolean;
80
119
  created: number;
81
120
  sessionID?: string | undefined;
82
121
  title?: string | undefined;
83
122
  parentID?: string | undefined;
84
- outcome?: "failed" | "succeeded" | "interrupted" | undefined;
123
+ outcome?: "failed" | "interrupted" | "succeeded" | undefined;
85
124
  updated?: number | undefined;
86
125
  idle?: number | undefined;
87
126
  lastText?: string | undefined;
88
127
  pending?: readonly Pending[] | undefined;
89
128
  } | {
90
129
  error: string;
130
+ group: {
131
+ name: string;
132
+ report: string;
133
+ };
134
+ directory: string;
135
+ isolated: boolean;
136
+ created: number;
137
+ sessionID: string;
138
+ title: string;
139
+ } | {
140
+ error: string;
141
+ group?: undefined;
91
142
  directory: string;
92
143
  isolated: boolean;
93
144
  created: number;
94
145
  sessionID: string;
95
146
  title: string;
96
147
  })[]>;
148
+ /** One roster entry as courier_children shows it: what courier_status reports with its directory and group standing, or the error it gave. */
149
+ export declare function described(ports: CourierPorts, entry: RosterEntry): Promise<{
150
+ group: {
151
+ name: string;
152
+ report: string;
153
+ };
154
+ directory: string;
155
+ isolated: boolean;
156
+ created: number;
157
+ sessionID?: string | undefined;
158
+ title?: string | undefined;
159
+ parentID?: string | undefined;
160
+ outcome?: "failed" | "interrupted" | "succeeded" | undefined;
161
+ updated?: number | undefined;
162
+ idle?: number | undefined;
163
+ lastText?: string | undefined;
164
+ pending?: readonly Pending[] | undefined;
165
+ } | {
166
+ group?: undefined;
167
+ directory: string;
168
+ isolated: boolean;
169
+ created: number;
170
+ sessionID?: string | undefined;
171
+ title?: string | undefined;
172
+ parentID?: string | undefined;
173
+ outcome?: "failed" | "interrupted" | "succeeded" | undefined;
174
+ updated?: number | undefined;
175
+ idle?: number | undefined;
176
+ lastText?: string | undefined;
177
+ pending?: readonly Pending[] | undefined;
178
+ } | {
179
+ error: string;
180
+ group: {
181
+ name: string;
182
+ report: string;
183
+ };
184
+ directory: string;
185
+ isolated: boolean;
186
+ created: number;
187
+ sessionID: string;
188
+ title: string;
189
+ } | {
190
+ error: string;
191
+ group?: undefined;
192
+ directory: string;
193
+ isolated: boolean;
194
+ created: number;
195
+ sessionID: string;
196
+ title: string;
197
+ }>;
97
198
  /** A readable message for a failed courier call; OpenCode's own errors can carry an empty message. */
98
199
  export declare function describeFailure(tool: string, error: unknown): Error;
99
200
  export {};