@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 +21 -0
- package/README.md +108 -0
- package/dist/block-watcher.js +137 -0
- package/dist/constants.js +25 -0
- package/dist/file-lock.js +106 -0
- package/dist/formatters.js +108 -0
- package/dist/idle-detector.js +49 -0
- package/dist/index.js +1 -0
- package/dist/opencode-queue.js +1 -0
- package/dist/plugin.js +370 -0
- package/dist/queue-manager.js +438 -0
- package/dist/queue-processor.js +273 -0
- package/dist/schedule-manager.js +126 -0
- package/dist/session-greeter.js +81 -0
- package/dist/shared-state.js +105 -0
- package/dist/toast.js +11 -0
- package/dist/types.js +5 -0
- package/dist/utils.js +29 -0
- package/package.json +58 -0
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
|
+
[](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";
|