opencode-courier 0.1.6 → 0.2.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.
package/dist/cleanup.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
1
+ import type { Plugin } from "@opencode/plugin";
2
2
  import { type RosterStorage } from "./roster.js";
3
3
  type Context = Plugin.Context;
4
4
  /** What would be lost by removing a worktree. */
@@ -14,8 +14,8 @@ export interface WorktreeState {
14
14
  export interface CleanupPorts {
15
15
  readonly storage: RosterStorage;
16
16
  readonly worktree: Pick<Context["worktree"], "remove">;
17
- /** The plugin's own location, for roster entries recorded before they carried their source. */
18
- readonly directory: string;
17
+ /** The project of the plugin's location, for roster entries recorded before they carried their project. */
18
+ readonly projectID: string;
19
19
  /** The worktree's state, or undefined when its directory no longer exists; `base` is the commit it was made from. */
20
20
  readonly inspect: (directory: string, base?: string) => Promise<WorktreeState | undefined>;
21
21
  }
package/dist/cleanup.js CHANGED
@@ -52,7 +52,7 @@ export async function cleanup(ports, parentID, input) {
52
52
  commits: state.commits.slice(0, MAX_LISTED),
53
53
  };
54
54
  await ports.worktree.remove({
55
- location: { directory: entry.source ?? ports.directory },
55
+ projectID: entry.project ?? ports.projectID,
56
56
  directory,
57
57
  force: input.force === true,
58
58
  });
package/dist/courier.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
1
+ import type { Plugin } from "@opencode/plugin";
2
2
  import type { Prompt } from "./question.js";
3
3
  import { type RosterStorage } from "./roster.js";
4
4
  type Context = Plugin.Context;
@@ -7,6 +7,8 @@ export interface CourierPorts {
7
7
  readonly session: Pick<Context["session"], "create" | "prompt" | "synthetic" | "get" | "context">;
8
8
  readonly agent: Pick<Context["agent"], "get">;
9
9
  readonly worktree: Pick<Context["worktree"], "create" | "remove">;
10
+ /** The project of the plugin's location; an isolated child's worktree is made in it, and removed from it. */
11
+ readonly projectID: string;
10
12
  /** The commit a directory's checkout is on, or undefined; recorded as an isolated child's base. */
11
13
  readonly head: (directory: string) => Promise<string | undefined>;
12
14
  readonly storage: RosterStorage;
package/dist/courier.js CHANGED
@@ -39,7 +39,7 @@ export async function spawn(ports, parentID, input) {
39
39
  // A failed lookup must not keep the child from starting; it then runs on OpenCode's default.
40
40
  const model = await inheritedModel(ports, parentID, input.agent).catch(() => undefined);
41
41
  const directory = input.isolate
42
- ? (await ports.worktree.create({ location: { directory: ports.directory } })).directory
42
+ ? (await ports.worktree.create({ projectID: ports.projectID })).directory
43
43
  : undefined;
44
44
  const base = directory ? await ports.head(directory) : undefined;
45
45
  const title = input.title ?? titleOf(input.task);
@@ -55,7 +55,7 @@ export async function spawn(ports, parentID, input) {
55
55
  // No session will ever use the fresh worktree, and nothing records it, so it goes now.
56
56
  if (directory)
57
57
  await ports.worktree
58
- .remove({ location: { directory: ports.directory }, directory, force: false })
58
+ .remove({ projectID: ports.projectID, directory, force: false })
59
59
  .catch(() => undefined);
60
60
  throw error;
61
61
  });
@@ -68,7 +68,7 @@ export async function spawn(ports, parentID, input) {
68
68
  directory: directory ?? child.location.directory,
69
69
  isolated: directory !== undefined,
70
70
  createdAt: ports.now(),
71
- ...(directory ? { source: ports.directory } : {}),
71
+ ...(directory ? { source: ports.directory, project: ports.projectID } : {}),
72
72
  ...(base ? { base } : {}),
73
73
  }).then(() => undefined, (error) => describeFailure("roster", error).message);
74
74
  await ports.session.prompt({ sessionID: child.id, text: childBrief(parentID, input.task) });
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { Plugin } from "@opencode-ai/plugin";
2
- import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect";
1
+ import { Plugin } from "@opencode/plugin";
2
+ import { Plugin as EffectPlugin } from "@opencode/plugin/effect";
3
3
  import { type QuestionPorts } from "./question.js";
4
4
  /** What a question relay needs from the plugin instance that wraps the question tool. */
5
5
  export interface RelaySlot {
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { Plugin } from "@opencode-ai/plugin";
2
- import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect";
3
- import { fromPromise } from "@opencode-ai/plugin/promise/adapter";
1
+ import { Plugin } from "@opencode/plugin";
2
+ import { Plugin as EffectPlugin } from "@opencode/plugin/effect";
3
+ import { fromPromise } from "@opencode/plugin/promise/adapter";
4
4
  import { Effect, Schema } from "effect";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { describeFailure, END_TURN, listChildren, send, spawn, status } from "./courier.js";
@@ -10,6 +10,7 @@ import { answer, pendingOf } from "./relay.js";
10
10
  import { answerQuestion, isQuestion, joinRelay, noticeCutOff, pendingQuestions, relayQuestions } from "./question.js";
11
11
  import { pruneExpired } from "./roster.js";
12
12
  import { watchChildren } from "./watch.js";
13
+ import { builtVersions, versionNotice } from "./version.js";
13
14
  import { listen, readConfig, subscribe, unsubscribe } from "./webhook.js";
14
15
  const SpawnInput = Schema.Struct({
15
16
  task: Schema.String.annotate({ description: "What the new session should do. It is told who started it and how to report back." }),
@@ -159,12 +160,24 @@ function describeAnswer(result) {
159
160
  export const courier = (relay = {}) => Plugin.define({
160
161
  id: "courier",
161
162
  setup: async (ctx) => {
163
+ const log = (message) => console.error(message);
164
+ // One line per load when this OpenCode is not the version the plugin was built against; the
165
+ // tools are registered all the same, and no tool result says so.
166
+ try {
167
+ const notice = versionNotice(builtVersions(), ctx.app);
168
+ if (notice)
169
+ log(notice);
170
+ }
171
+ catch (error) {
172
+ log(`courier: cannot compare the OpenCode version with the one it was built against: ${String(error)}`);
173
+ }
162
174
  const questionPorts = {
163
175
  storage: ctx.storage,
164
176
  session: ctx.session,
177
+ directory: ctx.location.directory,
165
178
  now: Date.now,
166
179
  newID: () => `question_${randomUUID()}`,
167
- log: (message) => console.error(message),
180
+ log,
168
181
  };
169
182
  const ports = {
170
183
  session: ctx.session,
@@ -172,6 +185,7 @@ export const courier = (relay = {}) => Plugin.define({
172
185
  worktree: ctx.worktree,
173
186
  storage: ctx.storage,
174
187
  directory: ctx.location.directory,
188
+ projectID: ctx.location.project.id,
175
189
  now: Date.now,
176
190
  head: headOf,
177
191
  pending: async (sessionID) => {
@@ -210,7 +224,7 @@ export const courier = (relay = {}) => Plugin.define({
210
224
  session: ctx.session,
211
225
  now: Date.now,
212
226
  newID: () => `later_${randomUUID()}`,
213
- log: (message) => console.error(message),
227
+ log,
214
228
  };
215
229
  const hooks = { storage: ctx.storage, session: ctx.session, now: Date.now, log: later.log };
216
230
  let webhook;
@@ -374,7 +388,7 @@ export const courier = (relay = {}) => Plugin.define({
374
388
  },
375
389
  });
376
390
  });
377
- void pruneExpired(ctx.storage, Date.now()).catch((error) => console.error(`courier roster prune: ${String(error)}`));
391
+ void pruneExpired(ctx.storage, Date.now()).catch((error) => log(`courier roster prune: ${String(error)}`));
378
392
  relay.ports = questionPorts;
379
393
  const leaveRelay = joinRelay(questionPorts);
380
394
  void noticeCutOff(questionPorts).catch((error) => questionPorts.log(`courier question: stored questions: ${String(error)}`));
package/dist/later.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
1
+ import type { Plugin } from "@opencode/plugin";
2
2
  import { type Storage } from "./storage.js";
3
3
  type Context = Plugin.Context;
4
4
  /** How often each plugin instance looks for due messages; a delivery can be this late. */
@@ -1,5 +1,5 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
2
- import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect";
1
+ import type { Plugin } from "@opencode/plugin";
2
+ import type { Plugin as EffectPlugin } from "@opencode/plugin/effect";
3
3
  import { Effect } from "effect";
4
4
  import { type RosterStorage } from "./roster.js";
5
5
  type Context = Plugin.Context;
@@ -44,17 +44,32 @@ export type Elsewhere = {
44
44
  /**
45
45
  * Timings, changed by the tests: how long a question cut off by a closing location waits before an
46
46
  * instance still loaded tells its top session, how long an answer waits for another one to it that
47
- * is still being passed on, and how long what follows a call's end waits for the notice of the
48
- * question to go out.
47
+ * is still being passed on, how long what follows a call's end waits for the notice of the
48
+ * question to go out, and how long a dismissal is held to see whether the location is shutting down.
49
49
  */
50
50
  export declare const timing: {
51
51
  closingGraceMs: number;
52
52
  passingWaitMs: number;
53
53
  relayWaitMs: number;
54
+ dismissalGraceMs: number;
54
55
  };
56
+ /**
57
+ * For tests: when the location at `directory` was last reported shutting down, if at all; a shutdown
58
+ * reported without a location counts for every directory, and with none given, any location's counts.
59
+ */
60
+ export declare const shutdownReportedAt: (directory?: string) => number | undefined;
61
+ /**
62
+ * Called for OpenCode's `location.shutdown`: the location at `directory` is closing, which
63
+ * withdraws its open forms as if the person had dismissed them (before unloading the plugin
64
+ * there, since OpenCode 2.0.22). The event's location is optional in the schema; without it,
65
+ * the shutdown counts for every location.
66
+ */
67
+ export declare function locationClosing(directory?: string): void;
55
68
  export interface QuestionPorts {
56
69
  readonly storage: RosterStorage;
57
70
  readonly session: Pick<Context["session"], "synthetic">;
71
+ /** The directory of the instance's location. */
72
+ readonly directory: string;
58
73
  readonly now: () => number;
59
74
  readonly newID: () => string;
60
75
  readonly log: (message: string) => void;
@@ -106,8 +121,13 @@ export declare function pendingQuestions(storage: RosterStorage, sessionID: stri
106
121
  * beyond the newest `MAX_STORED`, and tells the top session about the rest, which a restart cut off.
107
122
  */
108
123
  export declare function noticeCutOff(ports: QuestionPorts): Promise<void>;
109
- /** A plugin instance whose ports the relay may use, until the returned function is called. */
110
- export declare function joinRelay(ports: QuestionPorts): () => undefined;
124
+ /**
125
+ * A plugin instance whose ports the relay may use, until the returned function is called. A
126
+ * location loading again forgets the shutdown recorded for it, so a dismissal there is not
127
+ * mistaken for that shutdown. One recorded for every location is left alone, since a dismissal
128
+ * held in another location may still need it; it ages out with the grace.
129
+ */
130
+ export declare function joinRelay(ports: QuestionPorts): () => void;
111
131
  /** Called for OpenCode's `form.created`: a question call's form is shown, so its question can be relayed. */
112
132
  export declare function formShown(event: {
113
133
  readonly data: {
@@ -132,7 +152,7 @@ export declare function eventsFollowed(): boolean;
132
152
  /** Called when an instance's event stream ended or broke. */
133
153
  export declare function eventsLeft(): void;
134
154
  /** Wraps OpenCode's question tool for the relay. A tool by that id that is missing is left alone. */
135
- export declare function relayQuestions(host: Pick<EffectPlugin.Context, "tool">, ports: () => QuestionPorts | undefined): Effect.Effect<import("@opencode-ai/plugin/effect/registration").Registration, never, import("effect/Scope").Scope>;
155
+ export declare function relayQuestions(host: Pick<EffectPlugin.Context, "tool" | "location">, ports: () => QuestionPorts | undefined): Effect.Effect<import("@opencode/plugin/effect/registration").Registration, never, import("effect/Scope").Scope>;
136
156
  /** For tests: forgets every question this process knows. */
137
157
  export declare function forgetQuestions(): void;
138
158
  export {};
package/dist/question.js CHANGED
@@ -8,7 +8,7 @@ export const QUESTION_TOOL = "question";
8
8
  const PREFIX = "question/";
9
9
  /** At most this many questions are kept in storage; the oldest go first. */
10
10
  export const MAX_STORED = 100;
11
- const storedOf = ({ call: _call, link: _link, relaying: _relaying, settling: _settling, ...asked }) => asked;
11
+ const storedOf = ({ directory: _directory, call: _call, link: _link, relaying: _relaying, settling: _settling, ...asked }) => asked;
12
12
  const isPassing = (requestID) => shared.passing.has(requestID) || shared.answered.has(requestID);
13
13
  // Field by field, so a copy of the plugin loaded later in the process gets what an older copy lacks.
14
14
  const sharedState = (globalThis[Symbol.for("opencode-courier.questions")] ??= {});
@@ -20,14 +20,61 @@ sharedState.answered ??= new Set();
20
20
  sharedState.loaded ??= new Set();
21
21
  sharedState.following ??= 0;
22
22
  sharedState.followed ??= false;
23
+ sharedState.shutdowns ??= new Map();
24
+ sharedState.closingWaiters ??= new Set();
23
25
  const shared = sharedState;
24
26
  /**
25
27
  * Timings, changed by the tests: how long a question cut off by a closing location waits before an
26
28
  * instance still loaded tells its top session, how long an answer waits for another one to it that
27
- * is still being passed on, and how long what follows a call's end waits for the notice of the
28
- * question to go out.
29
+ * is still being passed on, how long what follows a call's end waits for the notice of the
30
+ * question to go out, and how long a dismissal is held to see whether the location is shutting down.
29
31
  */
30
- export const timing = { closingGraceMs: 30_000, passingWaitMs: 30_000, relayWaitMs: 30_000 };
32
+ export const timing = { closingGraceMs: 30_000, passingWaitMs: 30_000, relayWaitMs: 30_000, dismissalGraceMs: 2_000 };
33
+ /** The key of a shutdown reported without a location: it counts for every location. */
34
+ const ANYWHERE = "";
35
+ /** When the location at `directory` (any location, with none) was last reported shutting down; 0 if never. */
36
+ const shutdownAt = (directory) => directory === undefined
37
+ ? Math.max(0, ...shared.shutdowns.values())
38
+ : Math.max(shared.shutdowns.get(directory) ?? 0, shared.shutdowns.get(ANYWHERE) ?? 0);
39
+ /**
40
+ * For tests: when the location at `directory` was last reported shutting down, if at all; a shutdown
41
+ * reported without a location counts for every directory, and with none given, any location's counts.
42
+ */
43
+ export const shutdownReportedAt = (directory) => shutdownAt(directory) || undefined;
44
+ /**
45
+ * Called for OpenCode's `location.shutdown`: the location at `directory` is closing, which
46
+ * withdraws its open forms as if the person had dismissed them (before unloading the plugin
47
+ * there, since OpenCode 2.0.22). The event's location is optional in the schema; without it,
48
+ * the shutdown counts for every location.
49
+ */
50
+ export function locationClosing(directory) {
51
+ shared.shutdowns.set(directory ?? ANYWHERE, Date.now());
52
+ for (const wake of shared.closingWaiters)
53
+ wake();
54
+ }
55
+ /**
56
+ * Whether a dismissal just seen in the location at `directory` was that location closing rather
57
+ * than the person: true when it shut down within `ms` before, or does so, or this instance
58
+ * unloads, within `ms` from now. A shutdown reported without a location counts for every
59
+ * location, and with the directory unknown, any location's shutdown counts.
60
+ */
61
+ function closingSoon(loaded, directory, ms) {
62
+ const closing = () => !loaded() || Date.now() - shutdownAt(directory) <= ms;
63
+ if (closing())
64
+ return Promise.resolve(true);
65
+ return new Promise((resolve) => {
66
+ const done = (result) => {
67
+ clearTimeout(timer);
68
+ shared.closingWaiters.delete(wake);
69
+ resolve(result);
70
+ };
71
+ // Woken by any shutdown or unload: only one that answers the question ends the wait.
72
+ const wake = () => void (closing() && done(true));
73
+ const timer = setTimeout(() => done(false), ms);
74
+ timer.unref?.();
75
+ shared.closingWaiters.add(wake);
76
+ });
77
+ }
31
78
  /** The promise's value, or undefined once `ms` have passed or it failed. */
32
79
  function within(promise, ms) {
33
80
  if (!promise)
@@ -296,10 +343,20 @@ export async function noticeCutOff(ports) {
296
343
  await tellCutOff(ports, known ?? asked, "restarted").catch((error) => ports.log(`courier question: could not tell ${asked.top} about ${asked.requestID}: ${String(error)}`));
297
344
  }
298
345
  }
299
- /** A plugin instance whose ports the relay may use, until the returned function is called. */
346
+ /**
347
+ * A plugin instance whose ports the relay may use, until the returned function is called. A
348
+ * location loading again forgets the shutdown recorded for it, so a dismissal there is not
349
+ * mistaken for that shutdown. One recorded for every location is left alone, since a dismissal
350
+ * held in another location may still need it; it ages out with the grace.
351
+ */
300
352
  export function joinRelay(ports) {
301
353
  shared.loaded.add(ports);
302
- return () => void shared.loaded.delete(ports);
354
+ shared.shutdowns.delete(ports.directory);
355
+ return () => {
356
+ shared.loaded.delete(ports);
357
+ for (const wake of shared.closingWaiters)
358
+ wake();
359
+ };
303
360
  }
304
361
  /**
305
362
  * Tells the top session about a question its closing location cut off, a little later, through an
@@ -374,9 +431,13 @@ async function relay(ports, question) {
374
431
  }
375
432
  /**
376
433
  * After a relayed call ended: tidies up, and tells the top session if it was settled without it.
377
- * `unloaded` when the plugin instance that ran the call had been unloaded by then.
434
+ * `loaded` says whether the plugin instance that ran the call is still loaded.
378
435
  */
379
- async function settle(ports, question, exit, unloaded) {
436
+ async function settle(ports, question, exit, loaded) {
437
+ const unloaded = !loaded();
438
+ // A dismissal is held from now, alongside the wait below, so the grace is not spent waiting.
439
+ const dismissed = Exit.isFailure(exit) && isDismissal(exit.cause);
440
+ const held = dismissed && !unloaded ? closingSoon(loaded, question.directory, timing.dismissalGraceMs) : undefined;
380
441
  // Telling the top session first, so what follows does not overtake the notice or come before it
381
442
  // is stored; for a while only, so a notice that never returns does not hold everything else up.
382
443
  // `told` is false when the top session was not told, or not yet.
@@ -393,14 +454,18 @@ async function settle(ports, question, exit, unloaded) {
393
454
  await forget(ports, question);
394
455
  return;
395
456
  }
396
- // Cut off: the turn was stopped, or OpenCode is closing the location, which unloads the plugin
397
- // and then withdraws every open form there as if the person had dismissed it (on a server
398
- // shutdown, say). The question stays, answered by message from now on; a top session already
399
- // asking the person passes their answer on that way. A closing location is not told now: the
400
- // next load tells it, in this process or after a restart, or an instance still loaded does, a
401
- // little later, when the process is still running then.
402
- if (Exit.isFailure(exit) && (unloaded || (!isDismissal(exit.cause) && Exit.hasInterrupts(exit)))) {
403
- if (unloaded)
457
+ // Cut off: the turn was stopped, or OpenCode is closing the location, which withdraws every open
458
+ // form there as if the person had dismissed it (on a server shutdown, say), before unloading the
459
+ // plugin there (since 2.0.22) or after (the beta). So a dismissal is held for a moment, to see
460
+ // whether the location's shutdown or this instance's unload follows it. The question stays,
461
+ // answered by message from now on; a top session already asking the person passes their answer
462
+ // on that way. A closing location is not told now: the next load tells it, in this process or
463
+ // after a restart, or an instance still loaded does, a little later, when the process is still
464
+ // running then. The unload is looked at again here: it may have come during the wait above, after
465
+ // a held dismissal had already been taken for the person's.
466
+ const closing = unloaded || !loaded() || (held !== undefined && (await held));
467
+ if (Exit.isFailure(exit) && (closing || (!dismissed && Exit.hasInterrupts(exit)))) {
468
+ if (closing)
404
469
  tellLater(question);
405
470
  else if (!question.link)
406
471
  await tellCutOff(ports, question, "stopped");
@@ -433,7 +498,7 @@ async function settle(ports, question, exit, unloaded) {
433
498
  * the top is told, and whichever answer comes first ends the call, the other side's question being
434
499
  * withdrawn. Other sessions' calls run unchanged.
435
500
  */
436
- function asking(ports, original) {
501
+ function asking(ports, directory, original) {
437
502
  return (input, context) => Effect.gen(function* () {
438
503
  const current = ports();
439
504
  const chain = current ? yield* Effect.promise(() => lineage(current.storage, context.sessionID).catch(() => [])) : [];
@@ -453,6 +518,7 @@ function asking(ports, original) {
453
518
  ...(chain.length > 1 ? { startedBy: chain[0].parentID } : {}),
454
519
  questions: promptsOf(input),
455
520
  askedAt: current.now(),
521
+ directory,
456
522
  call: (outcome) => {
457
523
  if (handed)
458
524
  return accepted.then(() => false);
@@ -480,7 +546,7 @@ function asking(ports, original) {
480
546
  const byTopSession = Exit.isSuccess(exit) && exit.value.by === "top";
481
547
  if (!byTopSession)
482
548
  question.call = undefined;
483
- question.settling = settle(current, question, exit, ports() !== current)
549
+ question.settling = settle(current, question, exit, () => ports() === current)
484
550
  .catch((error) => current.log(`courier question: could not settle ${question.requestID}: ${String(error)}`))
485
551
  .finally(() => (question.settling = undefined));
486
552
  // After settling is set, which an answer the call did not take then waits for.
@@ -524,7 +590,7 @@ function linkFor(sessionID, questions) {
524
590
  * The question call of a session that a waiting question was relayed to, asking the person the
525
591
  * same: what they choose is passed on, and the call is withdrawn if the question is settled first.
526
592
  */
527
- function linking(ports, ask) {
593
+ function linking(ports, directory, ask) {
528
594
  return (input, context) => Effect.gen(function* () {
529
595
  const current = ports();
530
596
  const questions = promptsOf(input);
@@ -556,9 +622,12 @@ function linking(ports, ask) {
556
622
  return;
557
623
  // Not waited for, as in asking.
558
624
  void (async () => {
559
- // Dismissed by the person: so is the question they were asked for.
560
- if (isDismissal(exit.cause))
625
+ // Dismissed by the person, unless the location is closing (see settle): so is the question they were asked for.
626
+ if (isDismissal(exit.cause)) {
627
+ if (await closingSoon(() => ports() === current, directory, timing.dismissalGraceMs))
628
+ return;
561
629
  await deliver(current, linked, { dismissed: true });
630
+ }
562
631
  // Stopped while the asking call was cut off as well: nobody has been told yet.
563
632
  else if (!linked.call && shared.questions.get(linked.requestID) === linked)
564
633
  await tellCutOff(current, linked, "stopped");
@@ -601,9 +670,11 @@ function linking(ports, ask) {
601
670
  }
602
671
  /** Wraps OpenCode's question tool for the relay. A tool by that id that is missing is left alone. */
603
672
  export function relayQuestions(host, ports) {
673
+ // The wrapped tool runs the calls of the host's location: its shutdown is what withdraws their forms.
674
+ const { directory } = host.location;
604
675
  return host.tool.transform((editor) => {
605
676
  editor.update(QUESTION_TOOL, (tool) => {
606
- tool.execute = linking(ports, asking(ports, tool.execute));
677
+ tool.execute = linking(ports, directory, asking(ports, directory, tool.execute));
607
678
  });
608
679
  });
609
680
  }
@@ -617,4 +688,6 @@ export function forgetQuestions() {
617
688
  shared.loaded.clear();
618
689
  shared.following = 0;
619
690
  shared.followed = false;
691
+ shared.shutdowns.clear();
692
+ shared.closingWaiters.clear();
620
693
  }
package/dist/relay.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
1
+ import type { Plugin } from "@opencode/plugin";
2
2
  import { type Pending } from "./courier.js";
3
3
  import { type RosterStorage } from "./roster.js";
4
4
  type Context = Plugin.Context;
package/dist/relay.js CHANGED
@@ -91,7 +91,7 @@ export async function answer(ports, waiting, callerID, input) {
91
91
  waiting.delete(requestID);
92
92
  const message = reply === "reject" ? input.message || REJECTED : undefined;
93
93
  try {
94
- await found.domain.reply({ sessionID, requestID, reply, ...(message ? { message } : {}) });
94
+ await found.domain.reply({ sessionID, requestID, decision: reply, ...(message ? { message } : {}) });
95
95
  }
96
96
  catch (error) {
97
97
  if (await locate(ports, sessionID, requestID).catch(() => found)) {
package/dist/roster.d.ts CHANGED
@@ -12,8 +12,10 @@ export interface RosterEntry {
12
12
  readonly directory: string;
13
13
  readonly isolated: boolean;
14
14
  readonly createdAt: number;
15
- /** For an isolated child, the directory its worktree was made from; courier_cleanup removes it through there. */
15
+ /** For an isolated child, the directory its worktree was made from; for the record, nothing acts on it. */
16
16
  readonly source?: string;
17
+ /** For an isolated child, the project its worktree belongs to; courier_cleanup removes it from there. */
18
+ readonly project?: string;
17
19
  /** For an isolated child, the commit its worktree was made from; its own work is what came after. */
18
20
  readonly base?: string;
19
21
  }
package/dist/storage.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
1
+ import type { Plugin } from "@opencode/plugin";
2
2
  type Context = Plugin.Context;
3
3
  export type Storage = Pick<Context["storage"], "get" | "set" | "remove" | "scan">;
4
4
  /** Every value stored under `prefix`, following the scan's pages. */
@@ -0,0 +1,22 @@
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 the versions from the package's own package.json, which sits next to `dist/` in the
9
+ * published package as it does next to `src/` in the repository: `version`, and the exact
10
+ * `@opencode/plugin` version under devDependencies, the one installed for the build and both test
11
+ * suites; the peer dependency names the same exact version, as the copy of the plugin API that
12
+ * `opencode plugin add` installs next to the plugin. Throws when the file cannot be read or does
13
+ * not hold both.
14
+ */
15
+ export declare function builtVersions(): BuiltVersions;
16
+ /**
17
+ * The line for the server log when the running OpenCode is not the one the plugin was built
18
+ * against; undefined when it is. Logged once per plugin load, never in a tool result. An OpenCode
19
+ * whose plugin context does not report a version, as one from before the pinned API or after a
20
+ * change to it may not, is the case the line is for, so it still names the pin then.
21
+ */
22
+ export declare function versionNotice(built: BuiltVersions, app: Partial<Pick<App, "version" | "channel">> | undefined): string | undefined;
@@ -0,0 +1,35 @@
1
+ import { readFileSync } from "node:fs";
2
+ const README_TABLE = "https://github.com/ivopogace/opencode-courier#supported-opencode-version";
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.
10
+ */
11
+ export function builtVersions() {
12
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
13
+ const plugin = pkg.version;
14
+ const opencode = pkg.devDependencies?.["@opencode/plugin"];
15
+ if (typeof plugin !== "string")
16
+ throw new Error("package.json has no version");
17
+ if (typeof opencode !== "string")
18
+ throw new Error("package.json pins no @opencode/plugin under devDependencies");
19
+ return { plugin, opencode };
20
+ }
21
+ /**
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.
26
+ */
27
+ export function versionNotice(built, app) {
28
+ const version = typeof app?.version === "string" ? app.version : undefined;
29
+ if (version === built.opencode)
30
+ return undefined;
31
+ const running = version === undefined ? "this server reports no version" : `this server is ${version}`;
32
+ const channel = typeof app?.channel === "string" ? ` (channel ${app.channel})` : "";
33
+ return (`opencode-courier ${built.plugin} was built and tested against OpenCode ${built.opencode}; ${running}${channel}. ` +
34
+ `Its tools may fail: see the Supported OpenCode version table in the README, ${README_TABLE}`);
35
+ }
package/dist/watch.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
1
+ import type { Plugin } from "@opencode/plugin";
2
2
  import { type PermissionAsked, type PermissionReplied, type Waiting } from "./relay.js";
3
3
  import { type RosterStorage } from "./roster.js";
4
4
  type Context = Plugin.Context;
@@ -63,7 +63,8 @@ export declare function relayPending(ports: WatchPorts, state: WatchState): Prom
63
63
  /**
64
64
  * Follows OpenCode's events until `signal` aborts, telling parents when a spawned child's turn
65
65
  * fails, when it waits for a permission and when that request is answered without them, and
66
- * noting the question forms shown, which the question relay waits for.
66
+ * noting for the question relay the question forms shown, which it waits for, and the locations
67
+ * shutting down, whose withdrawn forms must not pass for dismissals.
67
68
  */
68
69
  export declare function watchChildren(ports: WatchPorts, state: WatchState, signal: AbortSignal, retryMs?: number): Promise<void>;
69
70
  export {};
package/dist/watch.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { envelope } from "./courier.js";
2
- import { eventsFollowed, eventsLeft, formShown, formsMayHaveBeenMissed } from "./question.js";
2
+ import { eventsFollowed, eventsLeft, formShown, formsMayHaveBeenMissed, locationClosing } from "./question.js";
3
3
  import { permissionNotice, settledNotice } from "./relay.js";
4
4
  import { allEntries, entriesOf, lineage } from "./roster.js";
5
5
  /** How long to wait before subscribing again after the event stream ended or broke. */
@@ -114,7 +114,10 @@ const pause = (ms, signal) => new Promise((resolve) => {
114
114
  }
115
115
  signal.addEventListener("abort", done);
116
116
  });
117
- /** Handles one event of OpenCode's stream; those that cannot concern a spawned child are ignored. */
117
+ /**
118
+ * Handles one event of OpenCode's stream; those that cannot concern a spawned child, or a question
119
+ * relayed for one, are ignored.
120
+ */
118
121
  async function handle(ports, state, event) {
119
122
  if (event.type === "session.execution.failed")
120
123
  return reportFailure(ports, state.seen, event);
@@ -125,12 +128,19 @@ async function handle(ports, state, event) {
125
128
  // Not claimed: every instance may resolve the same waiting call, which is harmless.
126
129
  if (event.type === "form.created")
127
130
  formShown(event);
131
+ // Not claimed either: a location closing withdraws its forms, which must not pass for dismissals.
132
+ // The event's location is optional; without one, the shutdown counts for every location.
133
+ if (event.type === "location.shutdown") {
134
+ const directory = event.location?.directory;
135
+ locationClosing(typeof directory === "string" ? directory : undefined);
136
+ }
128
137
  return [];
129
138
  }
130
139
  /**
131
140
  * Follows OpenCode's events until `signal` aborts, telling parents when a spawned child's turn
132
141
  * fails, when it waits for a permission and when that request is answered without them, and
133
- * noting the question forms shown, which the question relay waits for.
142
+ * noting for the question relay the question forms shown, which it waits for, and the locations
143
+ * shutting down, whose withdrawn forms must not pass for dismissals.
134
144
  */
135
145
  export async function watchChildren(ports, state, signal, retryMs = RESUBSCRIBE_MS) {
136
146
  while (!signal.aborted) {
package/dist/webhook.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
1
+ import type { Plugin } from "@opencode/plugin";
2
2
  import { type IncomingMessage, type Server } from "node:http";
3
3
  type Context = Plugin.Context;
4
4
  /** Bodies above this are refused; GitHub's own deliveries are capped at 25 MB, real ones are far smaller. */
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "opencode-courier",
3
- "version": "0.1.6",
4
- "description": "OpenCode V2 plugin: spawn sessions, message them, and wake idle sessions without polling.",
3
+ "version": "0.2.0",
4
+ "description": "An OpenCode V2 plugin that lets one session start other sessions, message them, and be woken by them, without polling.",
5
5
  "license": "MIT",
6
+ "author": "Ivo Pogace <71701666+ivopogace@users.noreply.github.com>",
7
+ "homepage": "https://github.com/ivopogace/opencode-courier#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/ivopogace/opencode-courier/issues"
10
+ },
6
11
  "repository": {
7
12
  "type": "git",
8
13
  "url": "git+https://github.com/ivopogace/opencode-courier.git"
@@ -35,10 +40,10 @@
35
40
  "effect": "4.0.0-rc.112"
36
41
  },
37
42
  "peerDependencies": {
38
- "@opencode-ai/plugin": "0.0.0-beta-19271"
43
+ "@opencode/plugin": "2.0.22"
39
44
  },
40
45
  "devDependencies": {
41
- "@opencode-ai/plugin": "0.0.0-beta-19271",
46
+ "@opencode/plugin": "2.0.22",
42
47
  "@types/bun": "1.3.14",
43
48
  "@types/node": "26.6.4",
44
49
  "typescript": "5.9.3"