@geckom/opencode-queue 0.1.2

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/plugin.js ADDED
@@ -0,0 +1,370 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { existsSync, statSync } from "fs";
3
+ import { resolve } from "path";
4
+ import { BlockWatcher } from "./block-watcher.js";
5
+ import { formatQueueItemFull, formatQueueItemLog, formatQueueItemSummary, formatScheduledTask } from "./formatters.js";
6
+ import { QueueProcessor } from "./queue-processor.js";
7
+ import { QueueManager } from "./queue-manager.js";
8
+ import { getSharedState } from "./shared-state.js";
9
+ import { safeToast } from "./toast.js";
10
+ function findQueueItem(queueManager, itemId) {
11
+ return queueManager.getItem(itemId) || queueManager.listItems().find((item) => item.id.startsWith(itemId));
12
+ }
13
+ function findItemBySession(queueManager, sessionId) {
14
+ return queueManager.listItems().find((item) => item.sessionId === sessionId);
15
+ }
16
+ /**
17
+ * OpenCode loads exported plugin functions from the module, so the runtime
18
+ * entrypoint must remain minimal even though the source is internally modular.
19
+ */
20
+ export const OpencodeQueuePlugin = async (ctx) => {
21
+ const client = ctx.client;
22
+ const shared = getSharedState(client, ctx.serverUrl);
23
+ await shared.initialized;
24
+ shared.cleanedUp = false;
25
+ const { queueManager, idleDetector, sessionGreeter: greeter } = shared;
26
+ const isCoordinator = !shared.coordinatorClaimed;
27
+ if (isCoordinator) {
28
+ shared.coordinatorClaimed = true;
29
+ await queueManager.resetRunningToPending();
30
+ const { registerProcessCleanup } = await import("./shared-state.js");
31
+ registerProcessCleanup(shared);
32
+ idleDetector.start();
33
+ shared.scheduleManager.start();
34
+ greeter.startBlockedReminders();
35
+ }
36
+ const blockWatcher = new BlockWatcher(queueManager, client);
37
+ if (isCoordinator) {
38
+ safeToast(client, "opencode-queue loaded", "info", 3000);
39
+ }
40
+ const hooks = {
41
+ "chat.message": async () => {
42
+ idleDetector.writeActivity();
43
+ },
44
+ "tool.execute.before": async () => {
45
+ idleDetector.writeActivity();
46
+ },
47
+ "tool.execute.after": async () => {
48
+ idleDetector.writeActivity();
49
+ },
50
+ tool: {
51
+ "queue-list": tool({
52
+ description: "Show queue items or one item in summary, full, or log view.",
53
+ args: {
54
+ itemId: tool.schema.string().optional().describe("Item ID or prefix"),
55
+ status: tool.schema.enum(["pending", "running", "blocked", "review_pending", "completed", "failed"]).optional().describe("Status filter"),
56
+ view: tool.schema.enum(["summary", "full", "log"]).optional().describe("Output style"),
57
+ },
58
+ async execute(args) {
59
+ if (args.itemId) {
60
+ const item = findQueueItem(queueManager, args.itemId);
61
+ if (!item)
62
+ return `Error: Item ${args.itemId} not found.`;
63
+ switch (args.view) {
64
+ case "log":
65
+ return formatQueueItemLog(client, item);
66
+ case "full":
67
+ return formatQueueItemFull(item);
68
+ default:
69
+ return formatQueueItemSummary(item);
70
+ }
71
+ }
72
+ const items = queueManager.listItems(args.status);
73
+ if (items.length === 0)
74
+ return "Queue is empty.";
75
+ return items.map((item) => formatQueueItemSummary(item)).join("\n\n");
76
+ },
77
+ }),
78
+ "queue-add": tool({
79
+ description: "Add a queue item.",
80
+ args: {
81
+ workspace: tool.schema.string().describe("Absolute workspace path"),
82
+ goal: tool.schema.string().describe("Task goal"),
83
+ parentItemId: tool.schema.string().optional().describe("Parent item ID or prefix"),
84
+ dependencyMode: tool.schema.enum(["review_pending", "completed"]).optional().describe("When parent unlocks this item"),
85
+ },
86
+ async execute(args) {
87
+ let parentId = null;
88
+ if (args.parentItemId) {
89
+ const parent = findQueueItem(queueManager, args.parentItemId);
90
+ if (!parent)
91
+ return `Error: Parent item ${args.parentItemId} not found.`;
92
+ parentId = parent.id;
93
+ }
94
+ const result = await queueManager.addItem(args.workspace, args.goal, {
95
+ parentItemId: parentId,
96
+ dependencyMode: args.dependencyMode,
97
+ });
98
+ if (!("id" in result))
99
+ return `Error: ${result.error}`;
100
+ let output = `Added ${result.id}.\nStatus: ${result.status}\nGoal: ${result.goal}`;
101
+ if (result.parentItemId)
102
+ output += `\nDepends: ${result.parentItemId} @ ${result.dependencyMode}`;
103
+ return output;
104
+ },
105
+ }),
106
+ "queue-confirm": tool({
107
+ description: "Mark a review item complete.",
108
+ args: {
109
+ itemId: tool.schema.string().describe("Item ID or prefix"),
110
+ },
111
+ async execute(args) {
112
+ const item = findQueueItem(queueManager, args.itemId);
113
+ if (!item)
114
+ return `Error: Item ${args.itemId} not found.`;
115
+ if (item.status !== "review_pending") {
116
+ return `Error: Item ${item.id} is not awaiting review (status: ${item.status}).`;
117
+ }
118
+ const now = new Date().toISOString();
119
+ await queueManager.updateItem(item.id, {
120
+ status: "completed",
121
+ completedAt: now,
122
+ reviewedAt: now,
123
+ staleDependency: false,
124
+ });
125
+ return `Item ${item.id} marked completed.`;
126
+ },
127
+ }),
128
+ "queue-followup": tool({
129
+ description: "Send follow-up on a review item.",
130
+ args: {
131
+ itemId: tool.schema.string().describe("Item ID or prefix"),
132
+ message: tool.schema.string().describe("Follow-up message"),
133
+ },
134
+ async execute(args) {
135
+ const item = findQueueItem(queueManager, args.itemId);
136
+ if (!item)
137
+ return `Error: Item ${args.itemId} not found.`;
138
+ if (item.status !== "review_pending") {
139
+ return `Error: Item ${item.id} is not awaiting review (status: ${item.status}).`;
140
+ }
141
+ if (!item.sessionId)
142
+ return `Error: Item ${item.id} has no session.`;
143
+ await queueManager.updateItemAndMoveToFront(item.id, {
144
+ status: "pending",
145
+ followupMessage: args.message,
146
+ completedAt: null,
147
+ reviewedAt: null,
148
+ result: null,
149
+ });
150
+ await queueManager.markDescendantsStale(item.id);
151
+ const processor = new QueueProcessor(queueManager, client, idleDetector, ctx.serverUrl);
152
+ await processor.processNext();
153
+ const finalItem = findQueueItem(queueManager, item.id);
154
+ if (finalItem?.status === "failed") {
155
+ return `Error: Follow-up for item ${item.id} failed: ${finalItem.error || "Unknown error"}`;
156
+ }
157
+ return `Follow-up sent for ${item.id}.`;
158
+ },
159
+ }),
160
+ "queue-remove": tool({
161
+ description: "Remove a queue item.",
162
+ args: {
163
+ itemId: tool.schema.string().describe("Item ID or prefix"),
164
+ },
165
+ async execute(args) {
166
+ const item = findQueueItem(queueManager, args.itemId);
167
+ if (!item)
168
+ return `Error: Item ${args.itemId} not found.`;
169
+ const dependents = queueManager.listItems().filter((candidate) => candidate.parentItemId === item.id);
170
+ if (dependents.length > 0) {
171
+ return `Error: Item ${item.id} has dependent tasks and cannot be removed.`;
172
+ }
173
+ if (item.sessionId) {
174
+ try {
175
+ await client.session.abort({
176
+ path: { id: item.sessionId },
177
+ query: { directory: item.workspace },
178
+ });
179
+ }
180
+ catch { }
181
+ }
182
+ const removed = await queueManager.removeItem(item.id);
183
+ return removed ? `Removed item ${item.id}.` : `Error: Could not remove item ${item.id}.`;
184
+ },
185
+ }),
186
+ "queue-retry": tool({
187
+ description: "Retry a failed item.",
188
+ args: {
189
+ itemId: tool.schema.string().describe("Item ID or prefix"),
190
+ },
191
+ async execute(args) {
192
+ const item = findQueueItem(queueManager, args.itemId);
193
+ if (!item)
194
+ return `Error: Item ${args.itemId} not found.`;
195
+ if (item.status !== "failed")
196
+ return `Error: Item ${item.id} is not failed (status: ${item.status}).`;
197
+ await queueManager.updateItem(item.id, {
198
+ status: "pending",
199
+ retryCount: 0,
200
+ nextRetryAt: null,
201
+ error: null,
202
+ staleDependency: false,
203
+ });
204
+ return `Item ${item.id} reset to pending.`;
205
+ },
206
+ }),
207
+ "queue-schedule-add": tool({
208
+ description: "Schedule a one-off or recurring task.",
209
+ args: {
210
+ workspace: tool.schema.string().describe("Absolute workspace path"),
211
+ goal: tool.schema.string().describe("Task goal"),
212
+ scheduledFor: tool.schema.string().optional().describe("ISO datetime for one-off task"),
213
+ cronExpression: tool.schema.string().optional().describe("Cron expression for recurring task"),
214
+ timezone: tool.schema.string().optional().describe("IANA timezone (detects local timezone by default)"),
215
+ parentItemId: tool.schema.string().optional().describe("Parent item ID or prefix for dependency"),
216
+ dependencyMode: tool.schema.enum(["review_pending", "completed"]).optional().describe("When parent unlocks this item"),
217
+ maxOccurrences: tool.schema.number().optional().describe("Auto-disable after N firings (recurring only)"),
218
+ },
219
+ async execute(args) {
220
+ if (!args.scheduledFor && !args.cronExpression) {
221
+ return "Error: Provide either scheduledFor (one-off) or cronExpression (recurring).";
222
+ }
223
+ if (args.scheduledFor && args.cronExpression) {
224
+ return "Error: Provide only one of scheduledFor or cronExpression, not both.";
225
+ }
226
+ const absWorkspace = resolve(args.workspace);
227
+ if (!existsSync(absWorkspace)) {
228
+ return `Error: Directory not found: ${absWorkspace}`;
229
+ }
230
+ if (!statSync(absWorkspace).isDirectory()) {
231
+ return `Error: Path is not a directory: ${absWorkspace}`;
232
+ }
233
+ if (args.scheduledFor) {
234
+ const fireDate = new Date(args.scheduledFor);
235
+ if (Number.isNaN(fireDate.getTime()))
236
+ return `Error: Invalid ISO datetime: ${args.scheduledFor}`;
237
+ if (fireDate.getTime() <= Date.now())
238
+ return "Error: scheduledFor must be in the future.";
239
+ }
240
+ if (args.cronExpression) {
241
+ const { validateCronExpression } = await import("cron");
242
+ if (!validateCronExpression(args.cronExpression).valid) {
243
+ return `Error: Invalid cron expression: ${args.cronExpression}`;
244
+ }
245
+ }
246
+ let parentId = null;
247
+ if (args.parentItemId) {
248
+ const parent = findQueueItem(queueManager, args.parentItemId);
249
+ if (!parent)
250
+ return `Error: Parent item ${args.parentItemId} not found.`;
251
+ parentId = parent.id;
252
+ }
253
+ const schedule = await shared.scheduleManager.addAndStart({
254
+ workspace: absWorkspace,
255
+ goal: args.goal,
256
+ scheduledFor: args.scheduledFor ?? null,
257
+ cronExpression: args.cronExpression ?? null,
258
+ timezone: args.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
259
+ enabled: true,
260
+ maxOccurrences: args.maxOccurrences ?? null,
261
+ parentItemId: parentId,
262
+ dependencyMode: args.dependencyMode === "completed" ? "completed" : "review_pending",
263
+ });
264
+ return formatScheduledTask(schedule);
265
+ },
266
+ }),
267
+ "queue-schedule-list": tool({
268
+ description: "List, remove, pause, or resume scheduled tasks.",
269
+ args: {
270
+ action: tool.schema.enum(["list", "remove", "pause", "resume"]).optional().describe("Action (default: list)"),
271
+ scheduleId: tool.schema.string().optional().describe("Target schedule ID"),
272
+ },
273
+ async execute(args) {
274
+ const action = args.action || "list";
275
+ if (action === "list") {
276
+ const schedules = queueManager.listSchedules();
277
+ if (schedules.length === 0)
278
+ return "No scheduled tasks.";
279
+ return schedules.map((schedule) => formatScheduledTask(schedule)).join("\n\n");
280
+ }
281
+ if (!args.scheduleId)
282
+ return `Error: scheduleId is required for ${action} action.`;
283
+ let scheduleId = args.scheduleId;
284
+ const schedule = queueManager.getSchedule(scheduleId);
285
+ if (!schedule) {
286
+ const match = queueManager.listSchedules().find((candidate) => candidate.id.startsWith(scheduleId));
287
+ if (!match)
288
+ return `Error: Schedule ${scheduleId} not found.`;
289
+ scheduleId = match.id;
290
+ }
291
+ if (action === "remove") {
292
+ const removed = await shared.scheduleManager.removeAndStop(scheduleId);
293
+ return removed ? `Removed schedule ${scheduleId}.` : `Error: Could not remove schedule ${scheduleId}.`;
294
+ }
295
+ if (action === "pause") {
296
+ const updated = await shared.scheduleManager.pause(scheduleId);
297
+ if (!updated)
298
+ return `Error: Schedule ${scheduleId} not found.`;
299
+ return `Paused schedule ${updated.id}.\n${formatScheduledTask(updated)}`;
300
+ }
301
+ if (action === "resume") {
302
+ const updated = await shared.scheduleManager.resume(scheduleId);
303
+ if (!updated)
304
+ return `Error: Schedule ${scheduleId} not found.`;
305
+ return `Resumed schedule ${updated.id}.\n${formatScheduledTask(updated)}`;
306
+ }
307
+ return `Error: Unknown action: ${action}`;
308
+ },
309
+ }),
310
+ },
311
+ };
312
+ if (isCoordinator) {
313
+ hooks.event = async ({ event }) => {
314
+ try {
315
+ await blockWatcher.handleEvent(event);
316
+ switch (event.type) {
317
+ case "session.created":
318
+ await greeter.onSessionCreated();
319
+ break;
320
+ case "session.updated":
321
+ case "command.executed":
322
+ case "tui.command.execute":
323
+ case "question.replied":
324
+ case "question.rejected":
325
+ idleDetector.writeActivity();
326
+ break;
327
+ case "permission.replied": {
328
+ idleDetector.writeActivity();
329
+ const props = event.properties;
330
+ if (props?.sessionID) {
331
+ const item = findItemBySession(queueManager, props.sessionID);
332
+ if (item?.status === "blocked" && item.blockedReason?.type === "permission") {
333
+ await queueManager.updateItem(item.id, {
334
+ status: "running",
335
+ blockedReason: { ...item.blockedReason, userResponse: props.response || "approved" },
336
+ });
337
+ const processor = new QueueProcessor(queueManager, client, idleDetector, ctx.serverUrl);
338
+ void processor.continueSession(item.id, props.sessionID, item.workspace);
339
+ }
340
+ }
341
+ break;
342
+ }
343
+ case "message.updated": {
344
+ idleDetector.writeActivity();
345
+ const info = event.properties?.info;
346
+ if (info?.sessionID) {
347
+ await greeter.onMessageUpdated(info.sessionID);
348
+ if (info.role === "user") {
349
+ const item = findItemBySession(queueManager, info.sessionID);
350
+ if (item?.status === "blocked" && item.blockedReason?.type === "question") {
351
+ await queueManager.updateItem(item.id, {
352
+ status: "running",
353
+ blockedReason: { ...item.blockedReason, userResponse: "answered via session" },
354
+ });
355
+ const processor = new QueueProcessor(queueManager, client, idleDetector, ctx.serverUrl);
356
+ void processor.continueSession(item.id, info.sessionID, item.workspace);
357
+ }
358
+ }
359
+ }
360
+ break;
361
+ }
362
+ case "session.idle":
363
+ break;
364
+ }
365
+ }
366
+ catch { }
367
+ };
368
+ }
369
+ return hooks;
370
+ };