opencode-courier 0.1.6 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +186 -568
- package/dist/cleanup.d.ts +3 -3
- package/dist/cleanup.js +1 -1
- package/dist/courier.d.ts +3 -1
- package/dist/courier.js +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +20 -6
- package/dist/later.d.ts +1 -1
- package/dist/question.d.ts +27 -7
- package/dist/question.js +95 -22
- package/dist/relay.d.ts +1 -1
- package/dist/relay.js +1 -1
- package/dist/roster.d.ts +3 -1
- package/dist/storage.d.ts +1 -1
- package/dist/version.d.ts +22 -0
- package/dist/version.js +35 -0
- package/dist/watch.d.ts +3 -2
- package/dist/watch.js +13 -3
- package/dist/webhook.d.ts +1 -1
- package/package.json +9 -4
package/dist/cleanup.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Plugin } from "@opencode
|
|
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
|
|
18
|
-
readonly
|
|
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
|
-
|
|
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
|
|
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({
|
|
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({
|
|
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
|
|
2
|
-
import { Plugin as EffectPlugin } from "@opencode
|
|
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
|
|
2
|
-
import { Plugin as EffectPlugin } from "@opencode
|
|
3
|
-
import { fromPromise } from "@opencode
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
|
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. */
|
package/dist/question.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Plugin } from "@opencode
|
|
2
|
-
import type { Plugin as EffectPlugin } from "@opencode
|
|
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,
|
|
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
|
-
/**
|
|
110
|
-
|
|
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
|
|
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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
* `
|
|
434
|
+
* `loaded` says whether the plugin instance that ran the call is still loaded.
|
|
378
435
|
*/
|
|
379
|
-
async function settle(ports, question, exit,
|
|
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
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
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()
|
|
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
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;
|
|
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
|
|
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;
|
package/dist/version.js
ADDED
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
4
|
-
"description": "OpenCode V2 plugin
|
|
3
|
+
"version": "0.2.1",
|
|
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
|
|
43
|
+
"@opencode/plugin": "2.0.23"
|
|
39
44
|
},
|
|
40
45
|
"devDependencies": {
|
|
41
|
-
"@opencode
|
|
46
|
+
"@opencode/plugin": "2.0.23",
|
|
42
47
|
"@types/bun": "1.3.14",
|
|
43
48
|
"@types/node": "26.6.4",
|
|
44
49
|
"typescript": "5.9.3"
|