opencode-courier 0.5.0 → 0.6.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.
@@ -1,17 +1,21 @@
1
1
  import type { Plugin } from "@opencode/plugin/tui";
2
- import type { AttentionSoundName } from "@opencode/plugin/tui/context";
3
2
  import { type AskedPermission, type AskedQuestion, type Asks, type TreeSession } from "./rpc.js";
4
3
  type Context = Plugin.Context;
5
4
  type FormInfo = NonNullable<ReturnType<Context["data"]["session"]["form"]["list"]>>[number];
6
5
  type FormField = FormInfo["fields"][number];
7
6
  type FormAnswer = Parameters<Context["data"]["session"]["form"]["reply"]>[0]["answer"];
8
- /** Something a session of the tree waits on the person for; `key` is unique across kinds. */
7
+ type FormValue = FormAnswer[string];
8
+ /**
9
+ * Something a session of the tree waits on the person for; `key` is unique across kinds. `prompted` marks a permission
10
+ * request the top session's courier_answer prompt asks about.
11
+ */
9
12
  export type Ask = {
10
13
  readonly key: string;
11
14
  readonly session: TreeSession;
12
15
  } & ({
13
16
  readonly kind: "permission";
14
17
  readonly request: AskedPermission;
18
+ readonly prompted?: boolean;
15
19
  } | {
16
20
  readonly kind: "question";
17
21
  readonly question: AskedQuestion;
@@ -23,23 +27,30 @@ export type Ask = {
23
27
  export declare const INBOX_COMMAND = "courier.asks";
24
28
  /** How long after the first of a burst of asks the inbox reads the tree again, to sound the new ones. */
25
29
  export declare const SETTLE_MS = 250;
26
- /** The asks of a tree in the order the inbox lists them: by the tree's order, each session's permissions, questions, then forms. */
30
+ /** The top session, at depth 0, as its own permission requests name it. */
31
+ export declare const topSession: (asks: Asks) => TreeSession;
32
+ /**
33
+ * The asks of a tree in the order the inbox lists them: the top session's own permission requests, then by the tree's
34
+ * order, each session's permissions, questions, then forms.
35
+ */
27
36
  export declare function asksOf(asks: Asks, forms: ReadonlyMap<string, ReadonlyArray<FormInfo>>): Ask[];
28
- /** Where an ask comes from: the titles from the top session down to the asking one, as "root › child › grandchild". */
29
- export declare function pathOf(asks: Asks, sessionID: string, topTitle: string): string;
37
+ /** Where an ask comes from: the titles from the top session down to the asking one, as "root › child › grandchild", each as the TUI knows it now, else as spawned. */
38
+ export declare function pathOf(asks: Asks, sessionID: string, topTitle: string, currentTitle?: (sessionID: string) => string | undefined): string;
30
39
  /** What an ask is, in one line. */
31
40
  export declare function askTitle(ask: Ask): string;
32
- /** The line under an ask: its depth and group, and that a question's call was cut off. */
41
+ /** The line under an ask: its depth (the top's own say so) and group, that a question's call was cut off, or that the top's courier_answer prompt asks for it. */
33
42
  export declare function askFooter(ask: Ask): string;
34
- /** The sound an ask arriving plays: a permission request's, or a question's for questions and forms. */
35
- export declare const soundOf: (ask: Ask) => AttentionSoundName;
36
- /** Whether a form field is shown, given the answers so far: hidden ones never, conditional ones when their conditions hold. */
37
- export declare function shown(field: FormField, answers: FormAnswer): boolean;
43
+ /** Whether a form field applies, given the answers so far (a hidden field's default among them): its `when` conditions hold, as OpenCode reads them (a condition on an unanswered field is false; on a multiselect, `eq` means includes). */
44
+ export declare function active(field: FormField, answers: FormAnswer): boolean;
45
+ /** What is wrong with a field's answer by the limits OpenCode's form service checks on reply, worded as its form view does; undefined when it fits. */
46
+ export declare function misfit(field: FormField, value: FormValue): string | undefined;
47
+ /** The limits of a typed field as a prompt's placeholder: "1-5", "at least 3 characters", "email"; undefined without any. */
48
+ export declare function limitsOf(field: FormField): string | undefined;
38
49
  /**
39
50
  * Adds the asks inbox: what the open session's courier tree waits on the person for, read through the person's RPC and
40
- * answered in dialogs, opened by its command; a sound for each new ask. Returns its opener and its cleanup.
51
+ * answered in dialogs, opened by its command, its open list read again on `follows`; a sound for each new ask.
41
52
  */
42
- export declare function addAsksInbox(ctx: Context): {
53
+ export declare function addAsksInbox(ctx: Context, follows: ReadonlySet<string>): {
43
54
  open: (sessionID?: string | undefined) => Promise<void>;
44
55
  cleanup: () => void;
45
56
  };
@@ -26,6 +26,29 @@ export declare function readNativeChildren(options: unknown): {
26
26
  type Times = Awaited<ReturnType<Context["session"]["get"]>>["time"];
27
27
  /** Whether a session with these times runs: it has never finished a turn, or something reached it after the last one ended. */
28
28
  export declare const busy: (time: Pick<Times, "updated" | "idle">) => boolean;
29
+ /**
30
+ * Whether a session a tree is read of is live: it runs (as the limits count it: busy, and its turn has not failed or
31
+ * been interrupted since its last prompt), waits on a request or form, or owes a report; one OpenCode no longer knows is not.
32
+ */
33
+ export declare const isLive: (time: Pick<Times, "updated" | "idle"> | undefined, waits: number, report: {
34
+ readonly owes: boolean;
35
+ readonly ended: boolean;
36
+ } | undefined) => boolean;
37
+ /** A session of a tree read, as `cut` takes it: where it is in the tree, when it was started, and whether it is live. */
38
+ export interface Cuttable {
39
+ readonly sessionID: string;
40
+ readonly parentID: string;
41
+ readonly createdAt: number;
42
+ readonly live: boolean;
43
+ }
44
+ /**
45
+ * Which sessions a listing of at most `max` keeps, of a tree read in the order it is drawn: the live ones first, then
46
+ * the newest, each with the sessions above it so the tree still draws; `leftOut` counts the rest.
47
+ */
48
+ export declare function cut<T extends Cuttable>(nodes: ReadonlyArray<T>, max: number): {
49
+ kept: T[];
50
+ leftOut: number;
51
+ };
29
52
  /** A session started with courier_spawn, recorded under the session that started it. */
30
53
  export interface RosterEntry {
31
54
  readonly sessionID: string;
@@ -25,6 +25,32 @@ export function readNativeChildren(options) {
25
25
  }
26
26
  /** Whether a session with these times runs: it has never finished a turn, or something reached it after the last one ended. */
27
27
  export const busy = (time) => time.idle === undefined || millis(time.updated) > millis(time.idle);
28
+ /**
29
+ * Whether a session a tree is read of is live: it runs (as the limits count it: busy, and its turn has not failed or
30
+ * been interrupted since its last prompt), waits on a request or form, or owes a report; one OpenCode no longer knows is not.
31
+ */
32
+ export const isLive = (time, waits, report) => time !== undefined && ((busy(time) && !report?.ended) || waits > 0 || (report?.owes ?? false));
33
+ /**
34
+ * Which sessions a listing of at most `max` keeps, of a tree read in the order it is drawn: the live ones first, then
35
+ * the newest, each with the sessions above it so the tree still draws; `leftOut` counts the rest.
36
+ */
37
+ export function cut(nodes, max) {
38
+ const byID = new Map(nodes.map((node) => [node.sessionID, node]));
39
+ const ranked = nodes
40
+ .map((node, order) => ({ node, order }))
41
+ .sort((a, b) => Number(b.node.live) - Number(a.node.live) || b.node.createdAt - a.node.createdAt || a.order - b.order);
42
+ const kept = new Set();
43
+ for (const { node } of ranked) {
44
+ const chain = [];
45
+ for (let above = node; above && !kept.has(above.sessionID); above = byID.get(above.parentID))
46
+ chain.push(above.sessionID);
47
+ if (kept.size + chain.length > max)
48
+ continue;
49
+ for (const sessionID of chain)
50
+ kept.add(sessionID);
51
+ }
52
+ return { kept: nodes.filter((node) => kept.has(node.sessionID)), leftOut: nodes.length - kept.size };
53
+ }
28
54
  /** `lineage` over roster entries already read. */
29
55
  export function lineageIn(entries, sessionID) {
30
56
  return lineageBy(bySession(entries), sessionID);
@@ -51,8 +51,8 @@ export declare function notePrompt(ports: WatchPorts, event: SessionEvent & {
51
51
  */
52
52
  export declare function noteInterrupted(ports: WatchPorts, event: SessionEvent): Promise<boolean>;
53
53
  /**
54
- * Drops the report state, and the groups, of a session OpenCode deleted, and the report state of the sessions it
55
- * started, whose reports can no longer be delivered; it leaves its own group. Returns the sessions forgotten.
54
+ * Forgets what a deleted session leaves (report state, groups) and, unless isolated or a parent on the roster, its
55
+ * entry. Returns the sessions forgotten.
56
56
  */
57
57
  export declare function noteDeleted(ports: WatchPorts, event: SessionEvent): Promise<string[]>;
58
58
  /** A `Waits` asking `suppliers` one after another, cheapest first: the first that waits answers. */
@@ -1,6 +1,6 @@
1
1
  import { envelope } from "../kernel/protocol.js";
2
2
  import { claim, SEEN_MAX, setBounded } from "../platform/bounded.js";
3
- import { forgetDeleted } from "./forget.js";
3
+ import { forgetChild, forgetDeleted } from "./forget.js";
4
4
  import { hasCompleteGroup, leave, outIn } from "./group.js";
5
5
  import { awaited, reportOf, stoppedSincePrompt } from "./report.js";
6
6
  import { childStore, groupStore } from "./store.js";
@@ -88,8 +88,8 @@ export async function noteInterrupted(ports, event) {
88
88
  return true;
89
89
  }
90
90
  /**
91
- * Drops the report state, and the groups, of a session OpenCode deleted, and the report state of the sessions it
92
- * started, whose reports can no longer be delivered; it leaves its own group. Returns the sessions forgotten.
91
+ * Forgets what a deleted session leaves (report state, groups) and, unless isolated or a parent on the roster, its
92
+ * entry. Returns the sessions forgotten.
93
93
  */
94
94
  export async function noteDeleted(ports, event) {
95
95
  const { sessionID } = event.data;
@@ -99,6 +99,8 @@ export async function noteDeleted(ports, event) {
99
99
  const entry = parentID === undefined ? undefined : await children.entry(parentID, sessionID);
100
100
  const member = entry && (await outIn(ports, entry));
101
101
  await forgetDeleted(ports, sessionID, forgotten, member, event.created);
102
+ if (parentID !== undefined && entry && !entry.isolated && started.length === 0)
103
+ await forgetChild(ports.storage, parentID, sessionID);
102
104
  return forgotten;
103
105
  }
104
106
  /** A `Waits` asking `suppliers` one after another, cheapest first: the first that waits answers. */
@@ -3,7 +3,7 @@ export { shapeContext } from "./context.js";
3
3
  export { lineageIn, nativeRootOf, readLimits, readNativeChildren } from "./domain.js";
4
4
  export { together, treeEvents, waitsOn } from "./events.js";
5
5
  export { deliverReleased } from "./group.js";
6
- export type { CleanupPorts, CourierPorts, Pending, PendingPrompt, RosterEntry, RosterStorage, SpawnGate, Waits, WatchPorts, WatchState, } from "./ports.js";
6
+ export type { CleanupPorts, CourierPorts, Pending, PendingPrompt, RosterEntry, RosterStorage, ShownForm, SpawnGate, Waits, WatchPorts, WatchState, } from "./ports.js";
7
7
  export { allEntries, answeringTop, lineage, pruneExpired, RETENTION_MS } from "./roster.js";
8
8
  export { subtree } from "./subtree.js";
9
9
  export { registerTreeRpc } from "./rpc-handlers.js";
@@ -1,4 +1,4 @@
1
- import type { CourierPorts, Pending } from "./ports.js";
1
+ import type { CourierPorts, Pending, ShownForm } from "./ports.js";
2
2
  import type { Tree, Wait } from "./rpc.js";
3
3
  type Messages = Awaited<ReturnType<CourierPorts["session"]["context"]>>;
4
4
  /** The last tool call in a session's messages, and when it was made. */
@@ -8,6 +8,11 @@ export declare function lastTool(messages: Messages): {
8
8
  } | undefined;
9
9
  /** A request as the panel says what a paused session waits on: a permission request, or its first question. */
10
10
  export declare const waitOf: (pending: Pending) => Wait;
11
- /** The tree panel's rows: the sessions courier_tree reads under a session, at most `maxTotal`, each as `row` reads it. */
11
+ /** A form a session shows, as the panel says what it waits on. */
12
+ export declare const formWait: (form: ShownForm) => Wait;
13
+ /**
14
+ * The tree panel's rows: the sessions under a session as courier_tree lists them, at most `maxTotal`, the live ones
15
+ * first, then the newest, each as `row` reads it; `truncated` counts the older ones left out.
16
+ */
12
17
  export declare function panel(ports: CourierPorts, rootID: string): Promise<Tree>;
13
18
  export {};
@@ -1,8 +1,8 @@
1
1
  import { busy } from "./domain.js";
2
- import { lastStatus, reportOf } from "./report.js";
2
+ import { lastStatus, stoppedSincePrompt } from "./report.js";
3
3
  import { standingIn } from "./status.js";
4
4
  import { childStore } from "./store.js";
5
- import { subtree } from "./subtree.js";
5
+ import { readTree } from "./subtree.js";
6
6
  /** The last tool call in a session's messages, and when it was made. */
7
7
  export function lastTool(messages) {
8
8
  for (const message of messages.toReversed()) {
@@ -18,6 +18,8 @@ export function lastTool(messages) {
18
18
  export const waitOf = (pending) => pending.type === "permission"
19
19
  ? { type: "permission", action: pending.action, resources: [...pending.resources] }
20
20
  : { type: "question", question: pending.questions[0]?.question ?? "" };
21
+ /** A form a session shows, as the panel says what it waits on. */
22
+ export const formWait = (form) => ({ type: "form", title: form.title });
21
23
  /** A session's report since its last prompt, as the panel shows it; none for one spawned before this was kept. */
22
24
  function reportRow(report, status) {
23
25
  if (!report)
@@ -28,7 +30,7 @@ function reportRow(report, status) {
28
30
  return { report: { state: "ended", progressed: report.progressed } };
29
31
  return { report: { state: "reported", progressed: report.progressed, ...(status ? { status } : {}) } };
30
32
  }
31
- /** What a session does: waiting on a request to the person, running, idle, or gone when OpenCode no longer knows it. */
33
+ /** What a session does: waiting on a request to the person or a form it shows them, running, idle, or gone when OpenCode no longer knows it. */
32
34
  function stateOf(info, waits, running) {
33
35
  if (!info)
34
36
  return "gone";
@@ -37,16 +39,13 @@ function stateOf(info, waits, running) {
37
39
  return running ? "running" : "idle";
38
40
  }
39
41
  /** One session of the panel: what it is doing, its report and group standing, its last tool call and its cost. */
40
- async function row(ports, entry, depth) {
42
+ async function row(ports, { entry, depth, info, pending, forms, report }) {
41
43
  const { sessionID } = entry;
42
- const children = childStore(ports.storage);
43
- const [info, messages, pending, report, status, standing] = await Promise.all([
44
- ports.session.get({ sessionID }).catch(() => undefined),
44
+ const [messages, status, standing, stopped] = await Promise.all([
45
45
  ports.session.context({ sessionID }).catch(() => []),
46
- ports.pending(sessionID).catch(() => []),
47
- reportOf(children, sessionID).catch(() => undefined),
48
- lastStatus(children, sessionID).catch(() => undefined),
46
+ lastStatus(childStore(ports.storage), sessionID).catch(() => undefined),
49
47
  entry.group ? standingIn(ports, entry, entry.group) : undefined,
48
+ stoppedSincePrompt(childStore(ports.storage), sessionID).catch(() => false),
50
49
  ]);
51
50
  const running = info !== undefined && busy(info.time);
52
51
  const tool = lastTool(messages);
@@ -55,8 +54,9 @@ async function row(ports, entry, depth) {
55
54
  parentID: entry.parentID,
56
55
  title: info?.title ?? entry.title,
57
56
  depth,
58
- state: stateOf(info, pending.length, running),
57
+ state: stateOf(info, pending.length + forms.length, running),
59
58
  ...(info?.outcome ? { outcome: info.outcome } : {}),
59
+ ...(stopped ? { stopped } : {}),
60
60
  ...reportRow(report, status),
61
61
  ...(entry.group && standing ? { group: { name: entry.group, standing } } : {}),
62
62
  isolated: entry.isolated,
@@ -64,12 +64,15 @@ async function row(ports, entry, depth) {
64
64
  ...(info && !running && info.time.idle !== undefined ? { idle: info.time.idle } : {}),
65
65
  ...(info ? { cost: info.cost } : {}),
66
66
  ...(tool ? { tool } : {}),
67
- waits: pending.map(waitOf),
67
+ waits: [...pending.map(waitOf), ...forms.map(formWait)],
68
68
  };
69
69
  }
70
- /** The tree panel's rows: the sessions courier_tree reads under a session, at most `maxTotal`, each as `row` reads it. */
70
+ /**
71
+ * The tree panel's rows: the sessions under a session as courier_tree lists them, at most `maxTotal`, the live ones
72
+ * first, then the newest, each as `row` reads it; `truncated` counts the older ones left out.
73
+ */
71
74
  export async function panel(ports, rootID) {
72
- const { nodes, truncated } = await subtree(ports.storage, rootID, ports.limits.maxTotal);
73
- const rows = await Promise.all(nodes.map(({ entry, depth }) => row(ports, entry, depth)));
74
- return { rows, ...(truncated ? { truncated: ports.limits.maxTotal } : {}) };
75
+ const { kept, leftOut } = await readTree(ports, rootID);
76
+ const rows = await Promise.all(kept.map((node) => row(ports, node)));
77
+ return { rows, ...(leftOut ? { truncated: leftOut } : {}) };
75
78
  }
@@ -133,6 +133,8 @@ export interface CourierPorts {
133
133
  readonly now: () => number;
134
134
  /** The permission requests and questions a session waits on, wherever they are pending. */
135
135
  readonly pending: (sessionID: string) => Promise<ReadonlyArray<Pending>>;
136
+ /** The forms a session shows, which only the person can answer: web search's, a plugin's; not the question tool's. */
137
+ readonly forms: (sessionID: string) => Promise<ReadonlyArray<ShownForm>>;
136
138
  readonly limits: Limits;
137
139
  /** The `nativeChildren` option: a child started without isolate is created with OpenCode's own `parentID`. */
138
140
  readonly nativeChildren?: boolean;
@@ -143,6 +145,11 @@ export interface CourierPorts {
143
145
  readonly nudge: () => void;
144
146
  readonly log: (message: string) => void;
145
147
  }
148
+ /** A form a session shows, as `CourierPorts.forms` lists it; `person/` keeps them. */
149
+ export interface ShownForm {
150
+ readonly id: string;
151
+ readonly title: string;
152
+ }
146
153
  /** One question a session waits on, as `Pending` shows it; `person/`'s `Prompt` has these fields. */
147
154
  export interface PendingPrompt {
148
155
  readonly question: string;
@@ -18,6 +18,11 @@ export declare function lineage(storage: RosterStorage, sessionID: string): Prom
18
18
  export declare function answeringTop(storage: RosterStorage, sessionID: string, callerID: string, what: string): Promise<string>;
19
19
  /** Whether a directory still exists; tests pass a fake. */
20
20
  export type Exists = (directory: string) => boolean;
21
+ /**
22
+ * Whether an entry is past retention at `now`: recorded more than `RETENTION_MS` before, unless it is an isolated
23
+ * child whose worktree exists, which is kept so it can still be cleaned up.
24
+ */
25
+ export declare const isExpired: (entry: RosterEntry, now: number, exists?: Exists) => boolean;
21
26
  /** A parent's children, oldest first, after dropping the expired ones. */
22
27
  export declare function current(storage: RosterStorage, parentID: string, now: number, exists?: Exists): Promise<RosterEntry[]>;
23
28
  /**
@@ -31,11 +31,13 @@ export async function answeringTop(storage, sessionID, callerID, what) {
31
31
  return top;
32
32
  }
33
33
  /**
34
- * Forgets the given entries that were recorded more than `RETENTION_MS` before `now`, and returns
35
- * the rest. An isolated child is kept while its worktree exists, so it can still be cleaned up.
34
+ * Whether an entry is past retention at `now`: recorded more than `RETENTION_MS` before, unless it is an isolated
35
+ * child whose worktree exists, which is kept so it can still be cleaned up.
36
36
  */
37
+ export const isExpired = (entry, now, exists = existsSync) => now - entry.createdAt > RETENTION_MS && !(entry.isolated && exists(entry.directory));
38
+ /** Forgets the given entries that are expired at `now`, and returns the rest. */
37
39
  async function dropExpired(storage, entries, now, exists) {
38
- const expired = new Set(entries.filter((entry) => now - entry.createdAt > RETENTION_MS && !(entry.isolated && exists(entry.directory))));
40
+ const expired = new Set(entries.filter((entry) => isExpired(entry, now, exists)));
39
41
  await Promise.all([...expired].map((entry) => forgetChild(storage, entry.parentID, entry.sessionID, entry.group)));
40
42
  return entries.filter((entry) => !expired.has(entry));
41
43
  }
@@ -14,24 +14,22 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
14
14
  }>;
15
15
  readonly output: import("effect/StandardSchema").StandardSchemaV1<{
16
16
  readonly running: number;
17
+ readonly waiting: number;
17
18
  readonly idle: number;
18
19
  readonly groups: readonly {
19
20
  readonly name: string;
20
21
  readonly reported: number;
21
22
  readonly members: number;
22
23
  }[];
23
- readonly waiting: number;
24
- readonly truncated?: number | undefined;
25
24
  }, {
26
25
  readonly running: number;
26
+ readonly waiting: number;
27
27
  readonly idle: number;
28
28
  readonly groups: readonly {
29
29
  readonly name: string;
30
30
  readonly reported: number;
31
31
  readonly members: number;
32
32
  }[];
33
- readonly waiting: number;
34
- readonly truncated?: number | undefined;
35
33
  }> & import("effect/Schema").Struct<{
36
34
  readonly running: import("effect/Schema").Int;
37
35
  readonly waiting: import("effect/Schema").Int;
@@ -41,7 +39,6 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
41
39
  readonly reported: import("effect/Schema").Int;
42
40
  readonly members: import("effect/Schema").Int;
43
41
  }>>;
44
- readonly truncated: import("effect/Schema").optionalKey<import("effect/Schema").Int>;
45
42
  }>;
46
43
  };
47
44
  readonly tree: {
@@ -68,11 +65,15 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
68
65
  } | {
69
66
  readonly type: "question";
70
67
  readonly question: string;
68
+ } | {
69
+ readonly type: "form";
70
+ readonly title: string;
71
71
  })[];
72
72
  readonly group?: {
73
73
  readonly name: string;
74
74
  readonly standing: string;
75
75
  } | undefined;
76
+ readonly stopped?: boolean | undefined;
76
77
  readonly idle?: number | undefined;
77
78
  readonly report?: {
78
79
  readonly progressed: boolean;
@@ -103,11 +104,15 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
103
104
  } | {
104
105
  readonly type: "question";
105
106
  readonly question: string;
107
+ } | {
108
+ readonly type: "form";
109
+ readonly title: string;
106
110
  })[];
107
111
  readonly group?: {
108
112
  readonly name: string;
109
113
  readonly standing: string;
110
114
  } | undefined;
115
+ readonly stopped?: boolean | undefined;
111
116
  readonly idle?: number | undefined;
112
117
  readonly report?: {
113
118
  readonly progressed: boolean;
@@ -130,6 +135,7 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
130
135
  readonly depth: import("effect/Schema").Int;
131
136
  readonly state: import("effect/Schema").Literals<readonly ["running", "waiting", "idle", "gone"]>;
132
137
  readonly outcome: import("effect/Schema").optionalKey<import("effect/Schema").Literals<readonly ["succeeded", "failed", "interrupted"]>>;
138
+ readonly stopped: import("effect/Schema").optionalKey<import("effect/Schema").Boolean>;
133
139
  readonly report: import("effect/Schema").optionalKey<import("effect/Schema").Struct<{
134
140
  readonly state: import("effect/Schema").Literals<readonly ["owed", "reported", "ended"]>;
135
141
  readonly status: import("effect/Schema").optionalKey<import("effect/Schema").String>;
@@ -154,6 +160,9 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
154
160
  }>, import("effect/Schema").Struct<{
155
161
  readonly type: import("effect/Schema").Literal<"question">;
156
162
  readonly question: import("effect/Schema").String;
163
+ }>, import("effect/Schema").Struct<{
164
+ readonly type: import("effect/Schema").Literal<"form">;
165
+ readonly title: import("effect/Schema").String;
157
166
  }>]>>;
158
167
  }>>;
159
168
  readonly truncated: import("effect/Schema").optionalKey<import("effect/Schema").Int>;
@@ -196,6 +205,37 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
196
205
  readonly refused: import("effect/StandardSchema").StandardSchemaV1<{}, {}> & import("effect/Schema").Struct<{}>;
197
206
  };
198
207
  };
208
+ readonly cleanup: {
209
+ readonly input: import("effect/StandardSchema").StandardSchemaV1<{
210
+ readonly sessionID: string;
211
+ readonly by: string;
212
+ }, {
213
+ readonly sessionID: string;
214
+ readonly by: string;
215
+ }> & import("effect/Schema").Struct<{
216
+ readonly sessionID: import("effect/Schema").String;
217
+ readonly by: import("effect/Schema").String;
218
+ }>;
219
+ readonly output: import("effect/StandardSchema").StandardSchemaV1<{
220
+ readonly outcome: "removed" | "gone" | "forgotten";
221
+ } | {
222
+ readonly outcome: "kept";
223
+ readonly reason: string;
224
+ }, {
225
+ readonly outcome: "removed" | "gone" | "forgotten";
226
+ } | {
227
+ readonly outcome: "kept";
228
+ readonly reason: string;
229
+ }> & import("effect/Schema").Union<readonly [import("effect/Schema").Struct<{
230
+ readonly outcome: import("effect/Schema").Literals<readonly ["forgotten", "removed", "gone"]>;
231
+ }>, import("effect/Schema").Struct<{
232
+ readonly outcome: import("effect/Schema").Literal<"kept">;
233
+ readonly reason: import("effect/Schema").String;
234
+ }>]>;
235
+ readonly errors: {
236
+ readonly refused: import("effect/StandardSchema").StandardSchemaV1<{}, {}> & import("effect/Schema").Struct<{}>;
237
+ };
238
+ };
199
239
  };
200
240
  readonly events: {};
201
241
  }>>;
@@ -1,9 +1,14 @@
1
+ import { describeFailure } from "../kernel/protocol.js";
2
+ import { isNotFound } from "../platform/json.js";
3
+ import { cleanup } from "./cleanup.js";
1
4
  import { panel } from "./panel.js";
2
5
  import { treeRpc } from "./rpc.js";
3
6
  import { stop } from "./stop.js";
7
+ import { childStore } from "./store.js";
4
8
  import { summary } from "./summary.js";
5
9
  /** Serves the tree's RPC at the plugin's location, through the same use cases as the tools; disposed with the plugin. */
6
10
  export function registerTreeRpc(rpc, ports) {
11
+ const refused = (cause) => (cause instanceof Error ? cause.message : String(cause));
7
12
  return rpc.register(treeRpc, {
8
13
  summary: ({ sessionID }) => summary(ports.courier, sessionID),
9
14
  tree: ({ sessionID }) => panel(ports.courier, sessionID),
@@ -13,8 +18,32 @@ export function registerTreeRpc(rpc, ports) {
13
18
  return { stopped: stopped.map(({ sessionID, title, outcome }) => ({ sessionID, title, outcome })), cancelled: cancelled.length };
14
19
  }
15
20
  catch (cause) {
16
- return error("refused", cause instanceof Error ? cause.message : String(cause), {});
21
+ return error("refused", refused(cause), {});
22
+ }
23
+ },
24
+ cleanup: async ({ sessionID, by }, { error }) => {
25
+ try {
26
+ return await forget(ports, by, sessionID);
27
+ }
28
+ catch (cause) {
29
+ return error("refused", refused(cause), {});
17
30
  }
18
31
  },
19
32
  });
20
33
  }
34
+ /**
35
+ * Forgets a session OpenCode no longer knows as courier_cleanup called from its own parent, once `by` is found above it,
36
+ * as courier_stop checks. A session OpenCode still has, or cannot say it lost, is refused: forgetting it loses its report.
37
+ */
38
+ async function forget(ports, by, sessionID) {
39
+ const chain = await childStore(ports.courier.storage).lineage(sessionID);
40
+ if (!chain.length)
41
+ throw new Error(`${sessionID} was not started with courier_spawn, so there is nothing to forget.`);
42
+ if (!chain.some((entry) => entry.parentID === by))
43
+ throw new Error(`${by} is not above ${sessionID}: only a session above it, in the line of sessions that started it with courier_spawn, can forget it.`);
44
+ const known = await ports.courier.session.get({ sessionID }).then(() => `${sessionID} is still known to OpenCode: only a gone session is forgotten here.`, (error) => (isNotFound(error) ? undefined : describeFailure("session.get", error).message));
45
+ if (known)
46
+ throw new Error(known);
47
+ const result = await cleanup(ports.cleanup, chain[0].parentID, { sessionID });
48
+ return result.outcome === "kept" ? { outcome: "kept", reason: result.reason } : { outcome: result.outcome };
49
+ }