opencode-courier 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +302 -2
- package/dist/cleanup.d.ts +59 -0
- package/dist/cleanup.js +98 -0
- package/dist/courier.d.ts +75 -0
- package/dist/courier.js +122 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +284 -0
- package/dist/later.d.ts +39 -0
- package/dist/later.js +76 -0
- package/dist/roster.d.ts +32 -0
- package/dist/roster.js +44 -0
- package/dist/storage.d.ts +6 -0
- package/dist/storage.js +12 -0
- package/dist/webhook.d.ts +103 -0
- package/dist/webhook.js +396 -0
- package/package.json +44 -4
package/dist/courier.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { current, record } from "./roster.js";
|
|
2
|
+
export function childBrief(parentID, task) {
|
|
3
|
+
return [
|
|
4
|
+
`You were started by session ${parentID} through opencode-courier.`,
|
|
5
|
+
"",
|
|
6
|
+
`When you finish, or need a decision you cannot make yourself, call courier_send with sessionID "${parentID}" and a short report.`,
|
|
7
|
+
"That message wakes the parent. It is the only way the parent hears from you, so do not end without sending it.",
|
|
8
|
+
"",
|
|
9
|
+
"Task:",
|
|
10
|
+
task,
|
|
11
|
+
].join("\n");
|
|
12
|
+
}
|
|
13
|
+
export function envelope(from, message, attributes = {}) {
|
|
14
|
+
const extra = Object.entries(attributes)
|
|
15
|
+
.map(([name, value]) => ` ${name}="${value}"`)
|
|
16
|
+
.join("");
|
|
17
|
+
return `<courier from="${from}"${extra}>\n${message}\n</courier>`;
|
|
18
|
+
}
|
|
19
|
+
function titleOf(task) {
|
|
20
|
+
const line = task.trim().split("\n")[0] ?? "";
|
|
21
|
+
return line.length > 60 ? `${line.slice(0, 57)}...` : line;
|
|
22
|
+
}
|
|
23
|
+
/** Creates a child session, hands it the task and returns at once; the child reports back with courier_send. */
|
|
24
|
+
export async function spawn(ports, parentID, input) {
|
|
25
|
+
const directory = input.isolate
|
|
26
|
+
? (await ports.worktree.create({ location: { directory: ports.directory } })).directory
|
|
27
|
+
: undefined;
|
|
28
|
+
const base = directory ? await ports.head(directory) : undefined;
|
|
29
|
+
const title = input.title ?? titleOf(input.task);
|
|
30
|
+
const child = await ports.session
|
|
31
|
+
.create({
|
|
32
|
+
title,
|
|
33
|
+
...(input.agent ? { agent: input.agent } : {}),
|
|
34
|
+
...(directory ? { location: { directory } } : {}),
|
|
35
|
+
metadata: { courier: { parentID } },
|
|
36
|
+
})
|
|
37
|
+
.catch(async (error) => {
|
|
38
|
+
// No session will ever use the fresh worktree, and nothing records it, so it goes now.
|
|
39
|
+
if (directory)
|
|
40
|
+
await ports.worktree
|
|
41
|
+
.remove({ location: { directory: ports.directory }, directory, force: false })
|
|
42
|
+
.catch(() => undefined);
|
|
43
|
+
throw error;
|
|
44
|
+
});
|
|
45
|
+
// Recorded before the prompt, so a child that exists is on the roster even if prompting fails. A
|
|
46
|
+
// failed write must not keep the child from its task, so it is reported instead of thrown.
|
|
47
|
+
const rosterError = await record(ports.storage, {
|
|
48
|
+
sessionID: child.id,
|
|
49
|
+
parentID,
|
|
50
|
+
title,
|
|
51
|
+
directory: directory ?? child.location.directory,
|
|
52
|
+
isolated: directory !== undefined,
|
|
53
|
+
createdAt: ports.now(),
|
|
54
|
+
...(directory ? { source: ports.directory } : {}),
|
|
55
|
+
...(base ? { base } : {}),
|
|
56
|
+
}).then(() => undefined, (error) => describeFailure("roster", error).message);
|
|
57
|
+
await ports.session.prompt({ sessionID: child.id, text: childBrief(parentID, input.task) });
|
|
58
|
+
return {
|
|
59
|
+
sessionID: child.id,
|
|
60
|
+
directory: directory ?? child.location.directory,
|
|
61
|
+
...(rosterError ? { rosterError } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Drops a message into another session's inbox; OpenCode wakes that session if it is idle. */
|
|
65
|
+
export async function send(ports, from, input) {
|
|
66
|
+
const delivered = await ports.session.synthetic({
|
|
67
|
+
sessionID: input.sessionID,
|
|
68
|
+
text: envelope(from, input.message),
|
|
69
|
+
description: `Message from ${from}`,
|
|
70
|
+
metadata: { source: "courier", from },
|
|
71
|
+
delivery: input.queue ? "queue" : "steer",
|
|
72
|
+
});
|
|
73
|
+
return { messageID: delivered.id };
|
|
74
|
+
}
|
|
75
|
+
/** A one-off look at a session, for check-ins; not meant to be called in a loop. */
|
|
76
|
+
export async function status(ports, input) {
|
|
77
|
+
const info = await ports.session.get({ sessionID: input.sessionID });
|
|
78
|
+
const messages = await ports.session.context({ sessionID: input.sessionID });
|
|
79
|
+
const last = messages.findLast((message) => message.type === "assistant");
|
|
80
|
+
const lastText = last?.type === "assistant"
|
|
81
|
+
? last.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")
|
|
82
|
+
: undefined;
|
|
83
|
+
return withoutUndefined({
|
|
84
|
+
sessionID: info.id,
|
|
85
|
+
title: info.title,
|
|
86
|
+
parentID: info.parentID,
|
|
87
|
+
outcome: info.outcome,
|
|
88
|
+
updated: info.time.updated,
|
|
89
|
+
idle: info.time.idle,
|
|
90
|
+
lastText,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** The sessions a parent started, each with what courier_status reports, or the error it gave. */
|
|
94
|
+
export async function listChildren(ports, parentID) {
|
|
95
|
+
const entries = await current(ports.storage, parentID, ports.now());
|
|
96
|
+
return Promise.all(entries.map(async (entry) => {
|
|
97
|
+
const roster = { directory: entry.directory, isolated: entry.isolated, created: entry.createdAt };
|
|
98
|
+
try {
|
|
99
|
+
return { ...(await status(ports, { sessionID: entry.sessionID })), ...roster };
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
return {
|
|
103
|
+
sessionID: entry.sessionID,
|
|
104
|
+
title: entry.title,
|
|
105
|
+
...roster,
|
|
106
|
+
error: describeFailure("courier_status", error).message,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
/** OpenCode leaves a tool call hanging when its metadata holds `undefined`, so results drop those keys. */
|
|
112
|
+
function withoutUndefined(value) {
|
|
113
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
114
|
+
}
|
|
115
|
+
/** A readable message for a failed courier call; OpenCode's own errors can carry an empty message. */
|
|
116
|
+
export function describeFailure(tool, error) {
|
|
117
|
+
const tagged = error;
|
|
118
|
+
const message = (typeof tagged?.message === "string" && tagged.message) ||
|
|
119
|
+
[tagged?._tag, tagged?.sessionID].filter((item) => typeof item === "string").join(" ") ||
|
|
120
|
+
String(error);
|
|
121
|
+
return new Error(`${tool} failed: ${message}`);
|
|
122
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { describeFailure, listChildren, send, spawn, status } from "./courier.js";
|
|
5
|
+
import { cleanup, headOf, inspectWorktree } from "./cleanup.js";
|
|
6
|
+
import { cancel, deliverDue, schedule, TICK_MS } from "./later.js";
|
|
7
|
+
import { pruneExpired } from "./roster.js";
|
|
8
|
+
import { listen, readConfig, subscribe, unsubscribe } from "./webhook.js";
|
|
9
|
+
const SpawnInput = Schema.Struct({
|
|
10
|
+
task: Schema.String.annotate({ description: "What the new session should do. It is told who started it and how to report back." }),
|
|
11
|
+
title: Schema.optional(Schema.String.annotate({ description: "Session title; defaults to the task's first line." })),
|
|
12
|
+
agent: Schema.optional(Schema.String.annotate({ description: "Agent to run the session with; defaults to the default agent." })),
|
|
13
|
+
isolate: Schema.optional(Schema.Boolean.annotate({ description: "Run the session in its own git worktree so parallel sessions don't share files." })),
|
|
14
|
+
});
|
|
15
|
+
const SendInput = Schema.Struct({
|
|
16
|
+
sessionID: Schema.String.annotate({ description: "The session to deliver to." }),
|
|
17
|
+
message: Schema.String.annotate({ description: "The message text." }),
|
|
18
|
+
queue: Schema.optional(Schema.Boolean.annotate({ description: "Wait until the target's current turn ends instead of steering it now." })),
|
|
19
|
+
});
|
|
20
|
+
const StatusInput = Schema.Struct({
|
|
21
|
+
sessionID: Schema.String.annotate({ description: "The session to look at." }),
|
|
22
|
+
});
|
|
23
|
+
const ChildrenInput = Schema.Struct({
|
|
24
|
+
sessionID: Schema.optional(Schema.String.annotate({ description: "The session whose children to list; defaults to this one." })),
|
|
25
|
+
});
|
|
26
|
+
const CleanupInput = Schema.Struct({
|
|
27
|
+
sessionID: Schema.String.annotate({ description: "The isolated child whose worktree to remove." }),
|
|
28
|
+
force: Schema.optional(Schema.Boolean.annotate({
|
|
29
|
+
description: "Remove it even with uncommitted changes or commits on no branch; that work is lost.",
|
|
30
|
+
})),
|
|
31
|
+
});
|
|
32
|
+
const LaterInput = Schema.Struct({
|
|
33
|
+
message: Schema.String.annotate({ description: "The message to deliver." }),
|
|
34
|
+
delayMinutes: Schema.optional(Schema.Number.annotate({ description: "Deliver this many minutes from now. Give this or at." })),
|
|
35
|
+
at: Schema.optional(Schema.String.annotate({ description: "Deliver at this ISO 8601 time. Give this or delayMinutes." })),
|
|
36
|
+
sessionID: Schema.optional(Schema.String.annotate({ description: "The session to deliver to; defaults to this one." })),
|
|
37
|
+
});
|
|
38
|
+
const CancelInput = Schema.Struct({
|
|
39
|
+
id: Schema.String.annotate({ description: "The id courier_later returned." }),
|
|
40
|
+
});
|
|
41
|
+
const SubscribeInput = Schema.Struct({
|
|
42
|
+
topic: Schema.String.annotate({
|
|
43
|
+
description: "owner/repo for every event of a GitHub repository, owner/repo#<number> for one pull request or issue " +
|
|
44
|
+
"(reviews, comments, completed CI runs), or a plain name for deliveries posted to /hook/<name>.",
|
|
45
|
+
}),
|
|
46
|
+
sessionID: Schema.optional(Schema.String.annotate({ description: "The session to subscribe; defaults to this one." })),
|
|
47
|
+
});
|
|
48
|
+
const UnsubscribeInput = Schema.Struct({
|
|
49
|
+
topic: Schema.optional(Schema.String.annotate({ description: "The topic to drop; all of this session's when omitted." })),
|
|
50
|
+
sessionID: Schema.optional(Schema.String.annotate({ description: "The session to unsubscribe; defaults to this one." })),
|
|
51
|
+
});
|
|
52
|
+
const receivers = (globalThis[Symbol.for("opencode-courier.receiver")] ??= {});
|
|
53
|
+
const sameSettings = (a, b) => a.port === b.port && a.host === b.host && a.secret === b.secret && a.maxBytes === b.maxBytes;
|
|
54
|
+
function startReceiver(config, log) {
|
|
55
|
+
const instances = new Set();
|
|
56
|
+
const receiver = {
|
|
57
|
+
config,
|
|
58
|
+
instances,
|
|
59
|
+
server: (receivers.closing ?? Promise.resolve())
|
|
60
|
+
.then(() => listen(config, () => instances.values().next().value))
|
|
61
|
+
.then((server) => {
|
|
62
|
+
log(`courier webhook: listening on http://${config.host}:${server.address().port}`);
|
|
63
|
+
return server;
|
|
64
|
+
}, (error) => {
|
|
65
|
+
log(`courier webhook: cannot listen on ${config.host}:${config.port}: ${String(error)}`);
|
|
66
|
+
if (receivers.current === receiver)
|
|
67
|
+
receivers.current = undefined;
|
|
68
|
+
return undefined;
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
return (receivers.current = receiver);
|
|
72
|
+
}
|
|
73
|
+
function joinReceiver(config, ports) {
|
|
74
|
+
const receiver = receivers.current ?? startReceiver(config, ports.log);
|
|
75
|
+
if (!sameSettings(receiver.config, config))
|
|
76
|
+
ports.log(`courier webhook: already running on ${receiver.config.host}:${receiver.config.port} with other settings ` +
|
|
77
|
+
"(port, host, secret or maxBytes); this location's are ignored. Set the webhook option once, in the global config.");
|
|
78
|
+
receiver.instances.add(ports);
|
|
79
|
+
return async () => {
|
|
80
|
+
receiver.instances.delete(ports);
|
|
81
|
+
if (receiver.instances.size > 0 || receivers.current !== receiver)
|
|
82
|
+
return;
|
|
83
|
+
receivers.current = undefined;
|
|
84
|
+
const closing = receiver.server.then((server) => new Promise((resolve) => {
|
|
85
|
+
if (!server)
|
|
86
|
+
return resolve();
|
|
87
|
+
server.closeAllConnections();
|
|
88
|
+
server.close(() => resolve());
|
|
89
|
+
}));
|
|
90
|
+
receivers.closing = closing;
|
|
91
|
+
await closing;
|
|
92
|
+
if (receivers.closing === closing)
|
|
93
|
+
receivers.closing = undefined;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// One claim set for every instance in the process: OpenCode sets the plugin up once per project
|
|
97
|
+
// location, and those instances share one storage.
|
|
98
|
+
const claimed = (globalThis[Symbol.for("opencode-courier.claimed")] ??=
|
|
99
|
+
new Set());
|
|
100
|
+
const rethrow = (tool) => (error) => {
|
|
101
|
+
throw describeFailure(tool, error);
|
|
102
|
+
};
|
|
103
|
+
function describeCleanup(result) {
|
|
104
|
+
if (result.outcome === "removed")
|
|
105
|
+
return `Removed the worktree ${result.directory} of ${result.sessionID}.`;
|
|
106
|
+
if (result.outcome === "gone")
|
|
107
|
+
return `The worktree ${result.directory} of ${result.sessionID} was already gone; dropped it from courier_children.`;
|
|
108
|
+
return `Kept the worktree ${result.directory} of ${result.sessionID}: it has ${result.reason}. Commit or branch what you want to keep, or call courier_cleanup again with force: true to discard it.`;
|
|
109
|
+
}
|
|
110
|
+
export default Plugin.define({
|
|
111
|
+
id: "courier",
|
|
112
|
+
setup: async (ctx) => {
|
|
113
|
+
const ports = {
|
|
114
|
+
session: ctx.session,
|
|
115
|
+
worktree: ctx.worktree,
|
|
116
|
+
storage: ctx.storage,
|
|
117
|
+
directory: ctx.location.directory,
|
|
118
|
+
now: Date.now,
|
|
119
|
+
head: headOf,
|
|
120
|
+
};
|
|
121
|
+
const cleanupPorts = { ...ports, inspect: inspectWorktree };
|
|
122
|
+
const later = {
|
|
123
|
+
storage: ctx.storage,
|
|
124
|
+
session: ctx.session,
|
|
125
|
+
now: Date.now,
|
|
126
|
+
newID: () => `later_${randomUUID()}`,
|
|
127
|
+
log: (message) => console.error(message),
|
|
128
|
+
};
|
|
129
|
+
const hooks = { storage: ctx.storage, session: ctx.session, now: Date.now, log: later.log };
|
|
130
|
+
let webhook;
|
|
131
|
+
try {
|
|
132
|
+
webhook = readConfig(ctx.options);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
later.log(`courier webhook: not started: ${error instanceof Error ? error.message : String(error)}`);
|
|
136
|
+
}
|
|
137
|
+
await ctx.tool.transform((tools) => {
|
|
138
|
+
tools.add({
|
|
139
|
+
name: "courier_spawn",
|
|
140
|
+
options: { codemode: false },
|
|
141
|
+
description: "Start a new OpenCode session on a task and return immediately. The session reports back with courier_send, " +
|
|
142
|
+
"which wakes this session. DO NOT poll it or call courier_status in a loop; end your turn and wait. For long " +
|
|
143
|
+
"tasks, also courier_later a check-in for yourself in case it never reports, and courier_cancel it when it does.",
|
|
144
|
+
input: SpawnInput,
|
|
145
|
+
execute: async (input, context) => {
|
|
146
|
+
const child = await spawn(ports, context.sessionID, input).catch(rethrow("courier_spawn"));
|
|
147
|
+
const warning = child.rosterError ? ` It is not on your courier_children list: ${child.rosterError}` : "";
|
|
148
|
+
return {
|
|
149
|
+
content: `Started session ${child.sessionID} in ${child.directory}. It will report back with courier_send.${warning}`,
|
|
150
|
+
metadata: child,
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
tools.add({
|
|
155
|
+
name: "courier_send",
|
|
156
|
+
options: { codemode: false },
|
|
157
|
+
description: "Deliver a message to another OpenCode session. If that session is idle, OpenCode starts a new turn for it. " +
|
|
158
|
+
"Use it to report back to the session that started you, or to steer a session you started.",
|
|
159
|
+
input: SendInput,
|
|
160
|
+
execute: async (input, context) => {
|
|
161
|
+
const delivered = await send(ports, context.sessionID, input).catch(rethrow("courier_send"));
|
|
162
|
+
return { content: `Delivered to ${input.sessionID}.`, metadata: delivered };
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
tools.add({
|
|
166
|
+
name: "courier_status",
|
|
167
|
+
options: { codemode: false },
|
|
168
|
+
description: "Look once at a session's state and its last reply, e.g. on a scheduled check-in. Not for waiting: " +
|
|
169
|
+
"sessions you started report back on their own.",
|
|
170
|
+
input: StatusInput,
|
|
171
|
+
execute: async (input) => {
|
|
172
|
+
const result = await status(ports, input).catch(rethrow("courier_status"));
|
|
173
|
+
return { content: JSON.stringify(result, null, 2), metadata: result };
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
tools.add({
|
|
177
|
+
name: "courier_children",
|
|
178
|
+
options: { codemode: false },
|
|
179
|
+
description: "List the sessions this one started with courier_spawn, with each one's state and last reply, e.g. after a " +
|
|
180
|
+
"compaction or restart. Like courier_status, for a one-off look, not for waiting.",
|
|
181
|
+
input: ChildrenInput,
|
|
182
|
+
execute: async (input, context) => {
|
|
183
|
+
const listed = await listChildren(ports, input.sessionID || context.sessionID).catch(rethrow("courier_children"));
|
|
184
|
+
return {
|
|
185
|
+
content: listed.length ? JSON.stringify(listed, null, 2) : "No sessions started with courier_spawn.",
|
|
186
|
+
metadata: { children: listed },
|
|
187
|
+
};
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
tools.add({
|
|
191
|
+
name: "courier_cleanup",
|
|
192
|
+
options: { codemode: false },
|
|
193
|
+
description: "Remove the git worktree of a session you started with isolate: true, once you have what you need from it, " +
|
|
194
|
+
"and drop it from courier_children. A worktree with uncommitted changes or commits on no branch is kept and " +
|
|
195
|
+
"the result lists them; commit or branch what you want, or pass force: true to discard it.",
|
|
196
|
+
input: CleanupInput,
|
|
197
|
+
execute: async (input, context) => {
|
|
198
|
+
const result = await cleanup(cleanupPorts, context.sessionID, input).catch(rethrow("courier_cleanup"));
|
|
199
|
+
return { content: describeCleanup(result), metadata: result };
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
tools.add({
|
|
203
|
+
name: "courier_later",
|
|
204
|
+
options: { codemode: false },
|
|
205
|
+
description: "Schedule a message for a session (this one by default), delivered when due and waking it if idle. " +
|
|
206
|
+
"Use it as a safety net when you start sessions: schedule a check-in, end your turn, and cancel it with " +
|
|
207
|
+
"courier_cancel if the child reports first. Survives server restarts; may arrive up to ~15 seconds late.",
|
|
208
|
+
input: LaterInput,
|
|
209
|
+
execute: async (input, context) => {
|
|
210
|
+
const entry = await schedule(later, context.sessionID, input).catch(rethrow("courier_later"));
|
|
211
|
+
const fireAt = new Date(entry.fireAt).toISOString();
|
|
212
|
+
return {
|
|
213
|
+
content: `Scheduled ${entry.id} for ${fireAt}, to ${entry.sessionID}. Cancel it with courier_cancel.`,
|
|
214
|
+
metadata: { id: entry.id, fireAt, sessionID: entry.sessionID },
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
tools.add({
|
|
219
|
+
name: "courier_cancel",
|
|
220
|
+
options: { codemode: false },
|
|
221
|
+
description: "Cancel a message scheduled with courier_later, e.g. because the child it was waiting for reported.",
|
|
222
|
+
input: CancelInput,
|
|
223
|
+
execute: async (input) => {
|
|
224
|
+
const cancelled = await cancel(later, input.id).catch(rethrow("courier_cancel"));
|
|
225
|
+
return {
|
|
226
|
+
content: cancelled ? `Cancelled ${input.id}.` : `Nothing pending under ${input.id}; it may have been delivered.`,
|
|
227
|
+
metadata: { id: input.id, cancelled },
|
|
228
|
+
};
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
tools.add({
|
|
232
|
+
name: "courier_subscribe",
|
|
233
|
+
options: { codemode: false },
|
|
234
|
+
description: "Wake a session (this one by default) when a webhook arrives for a topic: a GitHub repository, one of its " +
|
|
235
|
+
"pull requests or issues (reviews, comments, completed CI runs), or a named generic hook. Each matching " +
|
|
236
|
+
"delivery arrives as a message, queued behind any running turn. End your turn and wait; do not poll.",
|
|
237
|
+
input: SubscribeInput,
|
|
238
|
+
execute: async (input, context) => {
|
|
239
|
+
const sessionID = input.sessionID ?? context.sessionID;
|
|
240
|
+
const subscription = await subscribe(hooks, sessionID, input.topic).catch(rethrow("courier_subscribe"));
|
|
241
|
+
const receiving = (await receivers.current?.server) !== undefined;
|
|
242
|
+
const note = receiving
|
|
243
|
+
? ""
|
|
244
|
+
: " Note: no webhook receiver runs in this OpenCode server (see the plugin's webhook option), so nothing will arrive yet.";
|
|
245
|
+
return {
|
|
246
|
+
content: `Subscribed ${sessionID} to ${subscription.topic}.${note}`,
|
|
247
|
+
metadata: { sessionID, topic: subscription.topic, receiver: receiving },
|
|
248
|
+
};
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
tools.add({
|
|
252
|
+
name: "courier_unsubscribe",
|
|
253
|
+
options: { codemode: false },
|
|
254
|
+
description: "Stop webhook deliveries for a topic, or all of a session's topics, e.g. once its pull request is merged.",
|
|
255
|
+
input: UnsubscribeInput,
|
|
256
|
+
execute: async (input, context) => {
|
|
257
|
+
const sessionID = input.sessionID ?? context.sessionID;
|
|
258
|
+
const dropped = await unsubscribe(hooks, sessionID, input.topic).catch(rethrow("courier_unsubscribe"));
|
|
259
|
+
return {
|
|
260
|
+
content: dropped.length ? `Unsubscribed ${sessionID} from ${dropped.join(", ")}.` : `${sessionID} had no matching subscription.`,
|
|
261
|
+
metadata: { sessionID, dropped },
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
void pruneExpired(ctx.storage, Date.now()).catch((error) => console.error(`courier roster prune: ${String(error)}`));
|
|
267
|
+
let ticking = false;
|
|
268
|
+
const tick = async () => {
|
|
269
|
+
if (ticking)
|
|
270
|
+
return;
|
|
271
|
+
ticking = true;
|
|
272
|
+
await deliverDue(later, claimed)
|
|
273
|
+
.catch((error) => later.log(`courier_later scheduler: ${String(error)}`))
|
|
274
|
+
.finally(() => (ticking = false));
|
|
275
|
+
};
|
|
276
|
+
void tick();
|
|
277
|
+
const timer = setInterval(tick, TICK_MS);
|
|
278
|
+
const leave = webhook ? joinReceiver(webhook, hooks) : undefined;
|
|
279
|
+
return async () => {
|
|
280
|
+
clearInterval(timer);
|
|
281
|
+
await leave?.();
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
});
|
package/dist/later.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import { type Storage } from "./storage.js";
|
|
3
|
+
type Context = Plugin.Context;
|
|
4
|
+
/** How often each plugin instance looks for due messages; a delivery can be this late. */
|
|
5
|
+
export declare const TICK_MS = 15000;
|
|
6
|
+
export interface LaterEntry {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly sessionID: string;
|
|
9
|
+
readonly from: string;
|
|
10
|
+
readonly message: string;
|
|
11
|
+
readonly fireAt: number;
|
|
12
|
+
readonly createdAt: number;
|
|
13
|
+
}
|
|
14
|
+
export interface LaterPorts {
|
|
15
|
+
readonly storage: Storage;
|
|
16
|
+
readonly session: Pick<Context["session"], "synthetic">;
|
|
17
|
+
readonly now: () => number;
|
|
18
|
+
readonly newID: () => string;
|
|
19
|
+
readonly log: (message: string) => void;
|
|
20
|
+
}
|
|
21
|
+
export interface LaterInput {
|
|
22
|
+
readonly message: string;
|
|
23
|
+
readonly delayMinutes?: number;
|
|
24
|
+
readonly at?: string;
|
|
25
|
+
readonly sessionID?: string;
|
|
26
|
+
}
|
|
27
|
+
/** Stores a message for later delivery; the scheduler in `deliverDue` sends it once it is due. */
|
|
28
|
+
export declare function schedule(ports: LaterPorts, from: string, input: LaterInput): Promise<LaterEntry>;
|
|
29
|
+
/** Drops a pending message; false when there was none, e.g. it was already delivered. */
|
|
30
|
+
export declare function cancel(ports: LaterPorts, id: string): Promise<boolean>;
|
|
31
|
+
/**
|
|
32
|
+
* Delivers every due message once. OpenCode sets the plugin up once per project location, all in
|
|
33
|
+
* one process and over one storage, so the instances share `claimed`: an id is claimed
|
|
34
|
+
* synchronously, re-read after the claim (another instance may have just delivered it), delivered,
|
|
35
|
+
* and only then removed. A crash between delivery and removal delivers it again after a restart,
|
|
36
|
+
* which a check-in survives better than being lost.
|
|
37
|
+
*/
|
|
38
|
+
export declare function deliverDue(ports: LaterPorts, claimed: Set<string>): Promise<void>;
|
|
39
|
+
export {};
|
package/dist/later.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { envelope } from "./courier.js";
|
|
2
|
+
import { scanAll } from "./storage.js";
|
|
3
|
+
/** How often each plugin instance looks for due messages; a delivery can be this late. */
|
|
4
|
+
export const TICK_MS = 15_000;
|
|
5
|
+
const PREFIX = "later/";
|
|
6
|
+
function fireTime(now, input) {
|
|
7
|
+
if ((input.delayMinutes === undefined) === (input.at === undefined))
|
|
8
|
+
throw new Error("Give exactly one of delayMinutes or at.");
|
|
9
|
+
if (input.delayMinutes !== undefined) {
|
|
10
|
+
if (!Number.isFinite(input.delayMinutes) || input.delayMinutes < 0)
|
|
11
|
+
throw new Error("delayMinutes must be a number of minutes, zero or more.");
|
|
12
|
+
return now + Math.round(input.delayMinutes * 60_000);
|
|
13
|
+
}
|
|
14
|
+
const at = Date.parse(input.at);
|
|
15
|
+
if (Number.isNaN(at))
|
|
16
|
+
throw new Error(`at is not a date: ${input.at}`);
|
|
17
|
+
if (at < now)
|
|
18
|
+
throw new Error(`at is in the past: ${input.at}`);
|
|
19
|
+
return at;
|
|
20
|
+
}
|
|
21
|
+
/** Stores a message for later delivery; the scheduler in `deliverDue` sends it once it is due. */
|
|
22
|
+
export async function schedule(ports, from, input) {
|
|
23
|
+
const now = ports.now();
|
|
24
|
+
const entry = {
|
|
25
|
+
id: ports.newID(),
|
|
26
|
+
sessionID: input.sessionID ?? from,
|
|
27
|
+
from,
|
|
28
|
+
message: input.message,
|
|
29
|
+
fireAt: fireTime(now, input),
|
|
30
|
+
createdAt: now,
|
|
31
|
+
};
|
|
32
|
+
await ports.storage.set(PREFIX + entry.id, { ...entry });
|
|
33
|
+
return entry;
|
|
34
|
+
}
|
|
35
|
+
/** Drops a pending message; false when there was none, e.g. it was already delivered. */
|
|
36
|
+
export async function cancel(ports, id) {
|
|
37
|
+
if ((await ports.storage.get(PREFIX + id)) === undefined)
|
|
38
|
+
return false;
|
|
39
|
+
await ports.storage.remove(PREFIX + id);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
function pending(ports) {
|
|
43
|
+
return scanAll(ports.storage, PREFIX);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Delivers every due message once. OpenCode sets the plugin up once per project location, all in
|
|
47
|
+
* one process and over one storage, so the instances share `claimed`: an id is claimed
|
|
48
|
+
* synchronously, re-read after the claim (another instance may have just delivered it), delivered,
|
|
49
|
+
* and only then removed. A crash between delivery and removal delivers it again after a restart,
|
|
50
|
+
* which a check-in survives better than being lost.
|
|
51
|
+
*/
|
|
52
|
+
export async function deliverDue(ports, claimed) {
|
|
53
|
+
const now = ports.now();
|
|
54
|
+
for (const due of (await pending(ports)).filter((entry) => entry.fireAt <= now)) {
|
|
55
|
+
if (claimed.has(due.id))
|
|
56
|
+
continue;
|
|
57
|
+
claimed.add(due.id);
|
|
58
|
+
try {
|
|
59
|
+
if ((await ports.storage.get(PREFIX + due.id)) === undefined)
|
|
60
|
+
continue;
|
|
61
|
+
await ports.session
|
|
62
|
+
.synthetic({
|
|
63
|
+
sessionID: due.sessionID,
|
|
64
|
+
text: envelope(due.from, due.message, { scheduled: new Date(due.createdAt).toISOString() }),
|
|
65
|
+
description: `Scheduled message from ${due.from}`,
|
|
66
|
+
metadata: { source: "courier", from: due.from, scheduled: due.id },
|
|
67
|
+
delivery: "queue",
|
|
68
|
+
})
|
|
69
|
+
.catch((error) => ports.log(`courier_later ${due.id} to ${due.sessionID} not delivered: ${String(error)}`));
|
|
70
|
+
await ports.storage.remove(PREFIX + due.id);
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
claimed.delete(due.id);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
package/dist/roster.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Storage } from "./storage.js";
|
|
2
|
+
/**
|
|
3
|
+
* Entries older than this are dropped when their parent's roster is read, and on plugin setup,
|
|
4
|
+
* except isolated children whose worktree is still there: those stay until courier_cleanup.
|
|
5
|
+
*/
|
|
6
|
+
export declare const RETENTION_MS: number;
|
|
7
|
+
/** A session started with courier_spawn, recorded under the session that started it. */
|
|
8
|
+
export interface RosterEntry {
|
|
9
|
+
readonly sessionID: string;
|
|
10
|
+
readonly parentID: string;
|
|
11
|
+
readonly title: string;
|
|
12
|
+
readonly directory: string;
|
|
13
|
+
readonly isolated: boolean;
|
|
14
|
+
readonly createdAt: number;
|
|
15
|
+
/** For an isolated child, the directory its worktree was made from; courier_cleanup removes it through there. */
|
|
16
|
+
readonly source?: string;
|
|
17
|
+
/** For an isolated child, the commit its worktree was made from; its own work is what came after. */
|
|
18
|
+
readonly base?: string;
|
|
19
|
+
}
|
|
20
|
+
export type RosterStorage = Storage;
|
|
21
|
+
export declare function rosterKey(parentID: string, sessionID: string): string;
|
|
22
|
+
export declare function record(storage: RosterStorage, entry: RosterEntry): Promise<void>;
|
|
23
|
+
/** Removes a child from its parent's roster; false when it was not there. */
|
|
24
|
+
export declare function forget(storage: RosterStorage, parentID: string, sessionID: string): Promise<boolean>;
|
|
25
|
+
/** A parent's children, oldest first. */
|
|
26
|
+
export declare function children(storage: RosterStorage, parentID: string): Promise<RosterEntry[]>;
|
|
27
|
+
/** Whether a directory still exists; tests pass a fake. */
|
|
28
|
+
export type Exists = (directory: string) => boolean;
|
|
29
|
+
/** A parent's children, oldest first, after dropping the expired ones. */
|
|
30
|
+
export declare function current(storage: RosterStorage, parentID: string, now: number, exists?: Exists): Promise<RosterEntry[]>;
|
|
31
|
+
/** Drops expired entries of every parent, so parents that never list their children don't keep them forever. */
|
|
32
|
+
export declare function pruneExpired(storage: RosterStorage, now: number, exists?: Exists): Promise<void>;
|
package/dist/roster.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { scanAll } from "./storage.js";
|
|
3
|
+
/**
|
|
4
|
+
* Entries older than this are dropped when their parent's roster is read, and on plugin setup,
|
|
5
|
+
* except isolated children whose worktree is still there: those stay until courier_cleanup.
|
|
6
|
+
*/
|
|
7
|
+
export const RETENTION_MS = 14 * 24 * 60 * 60_000;
|
|
8
|
+
const PREFIX = "roster/";
|
|
9
|
+
export function rosterKey(parentID, sessionID) {
|
|
10
|
+
return `${PREFIX}${parentID}/${sessionID}`;
|
|
11
|
+
}
|
|
12
|
+
export async function record(storage, entry) {
|
|
13
|
+
await storage.set(rosterKey(entry.parentID, entry.sessionID), { ...entry });
|
|
14
|
+
}
|
|
15
|
+
/** Removes a child from its parent's roster; false when it was not there. */
|
|
16
|
+
export async function forget(storage, parentID, sessionID) {
|
|
17
|
+
const key = rosterKey(parentID, sessionID);
|
|
18
|
+
if ((await storage.get(key)) === undefined)
|
|
19
|
+
return false;
|
|
20
|
+
await storage.remove(key);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
/** A parent's children, oldest first. */
|
|
24
|
+
export async function children(storage, parentID) {
|
|
25
|
+
const entries = await scanAll(storage, `${PREFIX}${parentID}/`);
|
|
26
|
+
return entries.sort((a, b) => a.createdAt - b.createdAt);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Removes the given entries that were recorded more than `RETENTION_MS` before `now`, and returns
|
|
30
|
+
* the rest. An isolated child is kept while its worktree exists, so it can still be cleaned up.
|
|
31
|
+
*/
|
|
32
|
+
async function dropExpired(storage, entries, now, exists) {
|
|
33
|
+
const expired = new Set(entries.filter((entry) => now - entry.createdAt > RETENTION_MS && !(entry.isolated && exists(entry.directory))));
|
|
34
|
+
await Promise.all([...expired].map((entry) => storage.remove(rosterKey(entry.parentID, entry.sessionID))));
|
|
35
|
+
return entries.filter((entry) => !expired.has(entry));
|
|
36
|
+
}
|
|
37
|
+
/** A parent's children, oldest first, after dropping the expired ones. */
|
|
38
|
+
export async function current(storage, parentID, now, exists = existsSync) {
|
|
39
|
+
return dropExpired(storage, await children(storage, parentID), now, exists);
|
|
40
|
+
}
|
|
41
|
+
/** Drops expired entries of every parent, so parents that never list their children don't keep them forever. */
|
|
42
|
+
export async function pruneExpired(storage, now, exists = existsSync) {
|
|
43
|
+
await dropExpired(storage, await scanAll(storage, PREFIX), now, exists);
|
|
44
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
type Context = Plugin.Context;
|
|
3
|
+
export type Storage = Pick<Context["storage"], "get" | "set" | "remove" | "scan">;
|
|
4
|
+
/** Every value stored under `prefix`, following the scan's pages. */
|
|
5
|
+
export declare function scanAll<T>(storage: Pick<Storage, "scan">, prefix: string): Promise<T[]>;
|
|
6
|
+
export {};
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Every value stored under `prefix`, following the scan's pages. */
|
|
2
|
+
export async function scanAll(storage, prefix) {
|
|
3
|
+
const values = [];
|
|
4
|
+
let after;
|
|
5
|
+
do {
|
|
6
|
+
const page = await storage.scan({ prefix, ...(after ? { after } : {}) });
|
|
7
|
+
for (const entry of page.entries)
|
|
8
|
+
values.push(entry.value);
|
|
9
|
+
after = page.next;
|
|
10
|
+
} while (after);
|
|
11
|
+
return values;
|
|
12
|
+
}
|