@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Geckom
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # opencode-queue
2
+
3
+ [![Buy Me A Coffee](https://img.shields.io/badge/Support-Buy%20Me%20A%20Coffee-yellow?logo=buymeacoffee)](https://buymeacoffee.com/geckom)
4
+
5
+ An [OpenCode](https://opencode.ai) plugin that maintains a global task queue and processes queued work when OpenCode is idle.
6
+
7
+ ## Architecture
8
+
9
+ The source is split into focused modules under `src/`, while the runtime deploy still ends up as a single bundled plugin file for OpenCode.
10
+
11
+ - `src/plugin.ts` wires hooks and tools
12
+ - `src/queue-manager.ts` owns `queue.json` persistence and serialized state transitions
13
+ - `src/queue-processor.ts` runs the queue/session state machine
14
+ - `src/schedule-manager.ts` owns cron jobs and delegates persisted mutations back to `QueueManager`
15
+ - `src/testing.ts` exposes a test-only surface used by compiled-output tests
16
+
17
+ ## How it works
18
+
19
+ The plugin stores a shared queue in `~/.config/opencode/queue.json`. When OpenCode is idle, one process-wide queue processor picks the next pending item, creates or resumes a session for it, and monitors progress. Blocked items (permission requests, questions) hold the queue until resolved. Completed work enters a review state before final close-out.
20
+
21
+ ### Features
22
+
23
+ - **Queue management tools** — add, list, confirm, follow up, remove, and retry items
24
+ - **Idle processing** — automatically starts the next queued task when OpenCode is idle
25
+ - **Permission and question handling** — detects blocked sessions and auto-resumes when you respond through any opencode interface
26
+ - **Review gate** — finished work enters `review_pending` state for human sign-off before marking complete
27
+ - **Task dependencies** — parent-child relationships with configurable dependency modes
28
+ - **Retry with backoff** — transient processing errors requeue items as pending with increasing retry delays
29
+ - **Blocked reminders** — time-based reminder toasts for blocked queue items
30
+ - **Scheduled tasks** — one-off (run once at a specific time) or recurring (cron-based) scheduled items that automatically prepend to the front of the queue
31
+ - **Schedule management** — pause, resume, and remove scheduled tasks; automatic auto-disable after a configurable number of occurrences
32
+ - **Corruption-safe queue store handling** — preserves broken `queue.json` contents for recovery instead of silently resetting state
33
+ - **Hot-reload config** — change queue settings without restarting OpenCode
34
+
35
+ ## Tools
36
+
37
+ | Tool | Description |
38
+ |------|-------------|
39
+ | `queue-add` | Add a task to the queue |
40
+ | `queue-list` | List queue items, with optional status filter and view modes |
41
+ | `queue-confirm` | Mark a `review_pending` item as complete |
42
+ | `queue-followup` | Send a follow-up message on a `review_pending` item |
43
+ | `queue-remove` | Remove a queue item |
44
+ | `queue-retry` | Retry a failed item |
45
+ | `queue-schedule-add` | Schedule a one-off or recurring task |
46
+ | `queue-schedule-list` | List, pause, resume, or remove scheduled tasks |
47
+
48
+ ## Installation
49
+
50
+ Add to your `opencode.json`:
51
+
52
+ ```json
53
+ {
54
+ "$schema": "https://opencode.ai/config.json",
55
+ "plugin": [
56
+ "@geckom/opencode-queue"
57
+ ]
58
+ }
59
+ ```
60
+
61
+ Alternatively, install directly from GitHub:
62
+
63
+ ```json
64
+ {
65
+ "plugin": [
66
+ "@geckom/opencode-queue@git+https://github.com/geckom/opencode-queue.git"
67
+ ]
68
+ }
69
+ ```
70
+
71
+ ## Configuration
72
+
73
+ The queue reads its settings from `~/.config/opencode/queue.json`. Edit the `config` object there — changes apply immediately without restarting OpenCode.
74
+
75
+ | Setting | Default | Description |
76
+ |---------|---------|-------------|
77
+ | `idleTimeoutSeconds` | `3600` | Seconds of inactivity before the next item is processed |
78
+ | `blockedReminderMinutes` | `30` | Minutes between reminders for blocked items |
79
+ | `maxRetries` | `3` | Maximum retry attempts for failed items |
80
+ | `retryDelaysMinutes` | `[5, 10, 15]` | Delay in minutes before each retry attempt |
81
+ | `reminderIntervalMessages` | `30` | Messages between blocked-item reminders |
82
+ | `sessionTimeoutMinutes` | `60` | Maximum minutes to wait for a running session before marking it failed |
83
+
84
+ ## Development
85
+
86
+ Local tests use compiled output from `dist/`, including an internal test surface for the repo test suite. That test-only surface is not exported or published as part of the package contract.
87
+
88
+ ```bash
89
+ npm install
90
+ npm run build
91
+ npm test
92
+ ```
93
+
94
+ To deploy into your local OpenCode config:
95
+
96
+ ```bash
97
+ npm run build:runtime
98
+ ```
99
+
100
+ Smoke test the deployed plugin with:
101
+
102
+ ```bash
103
+ opencode --print-logs debug config
104
+ ```
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,137 @@
1
+ import { QueueManager } from "./queue-manager.js";
2
+ export class BlockWatcher {
3
+ queueManager;
4
+ client;
5
+ constructor(queueManager, client) {
6
+ this.queueManager = queueManager;
7
+ this.client = client;
8
+ }
9
+ async handleEvent(event) {
10
+ if (event.type === "permission.asked") {
11
+ const permission = event.properties;
12
+ if (!permission?.sessionID)
13
+ return;
14
+ const item = this.queueManager.listItems("running").find((candidate) => candidate.sessionId === permission.sessionID);
15
+ if (!item)
16
+ return;
17
+ const patterns = Array.isArray(permission.patterns) ? permission.patterns : [];
18
+ const details = [permission.permission, patterns.length > 0 ? `Patterns: ${patterns.join(", ")}` : null]
19
+ .filter(Boolean)
20
+ .join(" | ");
21
+ await this.queueManager.updateItem(item.id, {
22
+ status: "blocked",
23
+ blockedReason: {
24
+ type: "permission",
25
+ permissionId: typeof permission.id === "string" ? permission.id : null,
26
+ requestId: typeof permission.id === "string" ? permission.id : null,
27
+ details: details || "Permission request pending",
28
+ options: ["once", "always", "reject"],
29
+ userResponse: null,
30
+ },
31
+ });
32
+ return;
33
+ }
34
+ if (event.type === "question.asked") {
35
+ const question = event.properties;
36
+ if (!question?.sessionID)
37
+ return;
38
+ const item = this.queueManager.listItems("running").find((candidate) => candidate.sessionId === question.sessionID);
39
+ if (!item)
40
+ return;
41
+ const questions = Array.isArray(question.questions) ? question.questions : [];
42
+ const details = questions
43
+ .map((entry) => entry.question)
44
+ .filter((entry) => Boolean(entry))
45
+ .join(" | ");
46
+ const options = questions.flatMap((entry) => Array.isArray(entry.options)
47
+ ? entry.options.map((option) => option.label).filter((label) => Boolean(label))
48
+ : []);
49
+ await this.queueManager.updateItem(item.id, {
50
+ status: "blocked",
51
+ blockedReason: {
52
+ type: "question",
53
+ permissionId: null,
54
+ requestId: typeof question.id === "string" ? question.id : null,
55
+ details: details || "Question pending",
56
+ options: options.length > 0 ? options : null,
57
+ userResponse: null,
58
+ },
59
+ });
60
+ }
61
+ }
62
+ async checkForBlocks(item) {
63
+ if (!item.sessionId)
64
+ return false;
65
+ if (item.status === "blocked")
66
+ return true;
67
+ const q = { directory: item.workspace };
68
+ try {
69
+ const { data: messages } = await this.client.session.messages({
70
+ path: { id: item.sessionId },
71
+ query: q,
72
+ });
73
+ if (!messages)
74
+ return false;
75
+ for (const msg of messages) {
76
+ for (const part of msg.parts) {
77
+ if (part.type === "tool" && part.tool === "question" && part.state.status === "pending") {
78
+ const input = part.state.input;
79
+ await this.queueManager.updateItem(item.id, {
80
+ status: "blocked",
81
+ blockedReason: {
82
+ type: "question",
83
+ permissionId: null,
84
+ requestId: null,
85
+ details: String(input.text || input.message || input.question || JSON.stringify(input)),
86
+ options: Array.isArray(input.options) ? input.options.map(String) : null,
87
+ userResponse: null,
88
+ },
89
+ });
90
+ return true;
91
+ }
92
+ }
93
+ }
94
+ const { data: statusMap } = await this.client.session.status({ query: q });
95
+ if (statusMap) {
96
+ for (const [, sessionStatus] of Object.entries(statusMap)) {
97
+ if (sessionStatus?.type === "idle") {
98
+ return false;
99
+ }
100
+ }
101
+ }
102
+ }
103
+ catch { }
104
+ return false;
105
+ }
106
+ async respondToBlock(item, response) {
107
+ if (!item.blockedReason || !item.sessionId)
108
+ return false;
109
+ const q = { directory: item.workspace };
110
+ try {
111
+ if (item.blockedReason.type === "permission" && item.blockedReason.permissionId) {
112
+ const normalized = response.toLowerCase();
113
+ const allowAlways = normalized === "always";
114
+ const allowOnce = normalized === "yes" || normalized === "allow" || normalized === "once";
115
+ const reject = normalized === "no" || normalized === "reject";
116
+ await this.client.postSessionIdPermissionsPermissionId({
117
+ path: { id: item.sessionId, permissionID: item.blockedReason.permissionId },
118
+ body: { response: reject ? "reject" : allowAlways ? "always" : allowOnce ? "once" : "once" },
119
+ query: q,
120
+ });
121
+ }
122
+ else {
123
+ await this.client.session.prompt({
124
+ path: { id: item.sessionId },
125
+ query: q,
126
+ body: {
127
+ parts: [{ type: "text", text: response }],
128
+ },
129
+ });
130
+ }
131
+ return true;
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ }
137
+ }
@@ -0,0 +1,25 @@
1
+ import { join } from "path";
2
+ export const CONFIG_DIR = process.env.XDG_CONFIG_HOME || join(process.env.HOME, ".config");
3
+ export const OPENCODE_DIR = join(CONFIG_DIR, "opencode");
4
+ export const QUEUE_FILE = join(OPENCODE_DIR, "queue.json");
5
+ export const QUEUE_CORRUPTION_MARKER_FILE = join(OPENCODE_DIR, "queue.json.corrupt");
6
+ export const LAST_ACTIVITY_FILE = join(OPENCODE_DIR, "queue.last-activity");
7
+ export const LOCK_FILE = join(OPENCODE_DIR, "queue.lock");
8
+ export const STORE_LOCK_FILE = join(OPENCODE_DIR, "queue.store.lock");
9
+ export const PROCESSING_LOCK_STALE_MS = 120_000;
10
+ export const PROCESSING_LOCK_REFRESH_MS = 30_000;
11
+ export const STORE_LOCK_STALE_MS = 15_000;
12
+ export const STORE_LOCK_RETRY_MS = 50;
13
+ export const STORE_LOCK_WAIT_MS = 5_000;
14
+ export const SIGNAL_EXIT_CODE = {
15
+ SIGINT: 130,
16
+ SIGTERM: 143,
17
+ };
18
+ export const DEFAULT_CONFIG = {
19
+ idleTimeoutSeconds: 3600,
20
+ blockedReminderMinutes: 30,
21
+ maxRetries: 3,
22
+ retryDelaysMinutes: [5, 10, 15],
23
+ reminderIntervalMessages: 30,
24
+ sessionTimeoutMinutes: 60,
25
+ };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * File-based locking is the only concurrency primitive used by this plugin.
3
+ * All persisted queue state mutations must flow through this helper.
4
+ */
5
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
6
+ import { randomUUID } from "crypto";
7
+ import { CONFIG_DIR, LOCK_FILE, OPENCODE_DIR, PROCESSING_LOCK_REFRESH_MS, PROCESSING_LOCK_STALE_MS, } from "./constants.js";
8
+ import { sleep } from "./utils.js";
9
+ void CONFIG_DIR;
10
+ const LOCK_OWNERS = new Map();
11
+ export class FileLock {
12
+ static isFresh(lockFile = LOCK_FILE, staleMs = PROCESSING_LOCK_STALE_MS) {
13
+ try {
14
+ if (!existsSync(lockFile))
15
+ return false;
16
+ const stat = statSync(lockFile);
17
+ return Date.now() - stat.mtimeMs <= staleMs;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ static async acquire(lockFile = LOCK_FILE, staleMs = PROCESSING_LOCK_STALE_MS) {
24
+ try {
25
+ if (!existsSync(OPENCODE_DIR)) {
26
+ mkdirSync(OPENCODE_DIR, { recursive: true });
27
+ }
28
+ if (existsSync(lockFile)) {
29
+ const stat = statSync(lockFile);
30
+ if (Date.now() - stat.mtimeMs > staleMs) {
31
+ unlinkSync(lockFile);
32
+ }
33
+ else {
34
+ return false;
35
+ }
36
+ }
37
+ const owner = `${process.pid}:${randomUUID()}`;
38
+ const fd = openSync(lockFile, "wx");
39
+ try {
40
+ writeFileSync(fd, `${owner}\n${Date.now()}`, "utf-8");
41
+ }
42
+ finally {
43
+ closeSync(fd);
44
+ }
45
+ LOCK_OWNERS.set(lockFile, owner);
46
+ return true;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ static refresh(lockFile = LOCK_FILE) {
53
+ try {
54
+ const owner = LOCK_OWNERS.get(lockFile);
55
+ if (!owner)
56
+ return;
57
+ if (!existsSync(OPENCODE_DIR)) {
58
+ mkdirSync(OPENCODE_DIR, { recursive: true });
59
+ }
60
+ writeFileSync(lockFile, `${owner}\n${Date.now()}`, "utf-8");
61
+ }
62
+ catch { }
63
+ }
64
+ static startHeartbeat(lockFile = LOCK_FILE, refreshMs = PROCESSING_LOCK_REFRESH_MS) {
65
+ const timer = setInterval(() => {
66
+ this.refresh(lockFile);
67
+ }, refreshMs);
68
+ timer.unref?.();
69
+ return timer;
70
+ }
71
+ static stopHeartbeat(timer) {
72
+ if (timer) {
73
+ clearInterval(timer);
74
+ }
75
+ }
76
+ static release(lockFile = LOCK_FILE) {
77
+ try {
78
+ const owner = LOCK_OWNERS.get(lockFile);
79
+ if (!owner)
80
+ return;
81
+ if (existsSync(lockFile)) {
82
+ const currentOwner = readFileSync(lockFile, "utf-8").split("\n", 1)[0];
83
+ if (currentOwner === owner) {
84
+ unlinkSync(lockFile);
85
+ }
86
+ }
87
+ }
88
+ catch { }
89
+ LOCK_OWNERS.delete(lockFile);
90
+ }
91
+ static async withLock(lockFile, staleMs, retryMs, timeoutMs, work) {
92
+ const deadline = Date.now() + timeoutMs;
93
+ while (!(await this.acquire(lockFile, staleMs))) {
94
+ if (Date.now() >= deadline) {
95
+ throw new Error(`Timed out acquiring lock: ${lockFile}`);
96
+ }
97
+ await sleep(retryMs);
98
+ }
99
+ try {
100
+ return await work();
101
+ }
102
+ finally {
103
+ this.release(lockFile);
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,108 @@
1
+ export function formatScheduledTask(schedule) {
2
+ const kind = schedule.scheduledFor ? "one-off" : "recurring";
3
+ const status = schedule.enabled ? "enabled" : "disabled";
4
+ let line = `[${status.toUpperCase()}] ${schedule.id.substring(0, 8)} (${kind}) ${schedule.goal.substring(0, 80)}`;
5
+ if (schedule.scheduledFor)
6
+ line += `\nScheduled: ${schedule.scheduledFor}`;
7
+ if (schedule.cronExpression)
8
+ line += `\nCron: ${schedule.cronExpression}`;
9
+ line += `\nTimezone: ${schedule.timezone}`;
10
+ if (schedule.nextTriggerAt)
11
+ line += `\nNext: ${schedule.nextTriggerAt}`;
12
+ if (schedule.lastTriggeredAt)
13
+ line += `\nLast: ${schedule.lastTriggeredAt}`;
14
+ line += `\nOccurrences: ${schedule.occurrenceCount}`;
15
+ if (schedule.maxOccurrences !== null)
16
+ line += ` / ${schedule.maxOccurrences}`;
17
+ if (schedule.parentItemId)
18
+ line += `\nDepends: ${schedule.parentItemId.substring(0, 8)} @ ${schedule.dependencyMode}`;
19
+ return line;
20
+ }
21
+ export function formatQueueItemSummary(item) {
22
+ let line = `[${item.status.toUpperCase()}] ${item.id.substring(0, 8)} ${item.goal.substring(0, 80)}`;
23
+ if (item.parentItemId)
24
+ line += `\nDepends: ${item.parentItemId.substring(0, 8)} @ ${item.dependencyMode}`;
25
+ if (item.dependencyBlockedReason && item.status === "pending") {
26
+ line += `\nWaiting: ${item.dependencyBlockedReason.substring(0, 160)}`;
27
+ }
28
+ if (item.staleDependency)
29
+ line += `\nStale: Parent changed after this item became eligible`;
30
+ if (item.status === "blocked" && item.blockedReason) {
31
+ line += `\nBlocked: ${item.blockedReason.details.substring(0, 160)}`;
32
+ }
33
+ if (item.status === "review_pending" && item.result) {
34
+ line += `\nReview: ${item.result.substring(0, 160)}`;
35
+ }
36
+ if (item.status === "completed" && item.result) {
37
+ line += `\nResult: ${item.result.substring(0, 160)}`;
38
+ }
39
+ if (item.status === "failed" && item.error) {
40
+ line += `\nError: ${item.error.substring(0, 160)}`;
41
+ }
42
+ return line;
43
+ }
44
+ export function formatQueueItemFull(item) {
45
+ let output = `ID: ${item.id}\nStatus: ${item.status}\nGoal: ${item.goal}\nWorkspace: ${item.workspace}`;
46
+ if (item.parentItemId)
47
+ output += `\nParent: ${item.parentItemId}`;
48
+ output += `\nDependency Mode: ${item.dependencyMode}`;
49
+ if (item.dependencySatisfiedAt)
50
+ output += `\nDependency Satisfied: ${item.dependencySatisfiedAt}`;
51
+ if (item.dependencySourceStatus)
52
+ output += `\nDependency Source: ${item.dependencySourceStatus}`;
53
+ if (item.dependencyBlockedReason)
54
+ output += `\nDependency Waiting: ${item.dependencyBlockedReason}`;
55
+ if (item.staleDependency)
56
+ output += `\nStale Dependency: true`;
57
+ output += `\nCreated: ${item.createdAt}`;
58
+ if (item.startedAt)
59
+ output += `\nStarted: ${item.startedAt}`;
60
+ if (item.completedAt)
61
+ output += `\nCompleted: ${item.completedAt}`;
62
+ if (item.reviewedAt)
63
+ output += `\nReviewed: ${item.reviewedAt}`;
64
+ if (item.sessionId)
65
+ output += `\nSession: ${item.sessionId}`;
66
+ if (item.sessionUrl)
67
+ output += `\nURL: ${item.sessionUrl}`;
68
+ if (item.retryCount > 0)
69
+ output += `\nRetries: ${item.retryCount}`;
70
+ if (item.nextRetryAt)
71
+ output += `\nNext Retry: ${item.nextRetryAt}`;
72
+ if (item.blockedReason)
73
+ output += `\nBlocked (${item.blockedReason.type}): ${item.blockedReason.details}`;
74
+ if (item.status === "review_pending" && item.result)
75
+ output += `\nReview Result: ${item.result}`;
76
+ else if (item.result)
77
+ output += `\nResult: ${item.result}`;
78
+ if (item.error)
79
+ output += `\nError: ${item.error}`;
80
+ return output;
81
+ }
82
+ export async function formatQueueItemLog(client, item) {
83
+ if (!item.sessionId)
84
+ return `No session for item ${item.id}.`;
85
+ const output = `Session: ${item.sessionId}\nURL: ${item.sessionUrl || "N/A"}`;
86
+ try {
87
+ const { data: messages } = await client.session.messages({
88
+ path: { id: item.sessionId },
89
+ query: { directory: item.workspace },
90
+ });
91
+ if (!messages || messages.length === 0) {
92
+ return `${output}\n\n(No messages)`;
93
+ }
94
+ const lines = [];
95
+ for (const msg of messages.slice(-4)) {
96
+ const role = msg.info.role;
97
+ const textParts = msg.parts.filter((part) => part.type === "text");
98
+ for (const part of textParts) {
99
+ const text = part.text;
100
+ lines.push(`[${role}] ${text.substring(0, 300)}`);
101
+ }
102
+ }
103
+ return lines.length > 0 ? `${output}\n\n${lines.join("\n\n")}` : `${output}\n\n(No text messages)`;
104
+ }
105
+ catch {
106
+ return `${output}\n\n(Could not fetch messages)`;
107
+ }
108
+ }
@@ -0,0 +1,49 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { LAST_ACTIVITY_FILE, OPENCODE_DIR } from "./constants.js";
3
+ /**
4
+ * IdleDetector is intentionally filesystem-backed so all plugin instances share
5
+ * the same notion of activity.
6
+ */
7
+ export class IdleDetector {
8
+ timer = null;
9
+ getConfig;
10
+ onIdle;
11
+ constructor(getConfig, onIdle) {
12
+ this.getConfig = getConfig;
13
+ this.onIdle = onIdle;
14
+ }
15
+ start() {
16
+ this.writeActivity();
17
+ this.timer = setInterval(() => void this.checkIdle(), 30_000);
18
+ this.timer.unref?.();
19
+ }
20
+ stop() {
21
+ if (this.timer) {
22
+ clearInterval(this.timer);
23
+ this.timer = null;
24
+ }
25
+ }
26
+ writeActivity() {
27
+ try {
28
+ if (!existsSync(OPENCODE_DIR)) {
29
+ mkdirSync(OPENCODE_DIR, { recursive: true });
30
+ }
31
+ writeFileSync(LAST_ACTIVITY_FILE, Date.now().toString(), "utf-8");
32
+ }
33
+ catch { }
34
+ }
35
+ async checkIdle() {
36
+ try {
37
+ if (!existsSync(LAST_ACTIVITY_FILE)) {
38
+ await this.onIdle();
39
+ return;
40
+ }
41
+ const lastActivity = Number.parseInt(readFileSync(LAST_ACTIVITY_FILE, "utf-8").trim(), 10);
42
+ const elapsed = Date.now() - lastActivity;
43
+ if (elapsed >= this.getConfig().idleTimeoutSeconds * 1000) {
44
+ await this.onIdle();
45
+ }
46
+ }
47
+ catch { }
48
+ }
49
+ }
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { OpencodeQueuePlugin as default } from "./plugin.js";
@@ -0,0 +1 @@
1
+ export { OpencodeQueuePlugin as default } from "./plugin.js";