@prevalentware/opencode-loop-plugin 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 +195 -0
- package/dist/server.js +924 -0
- package/package.json +76 -0
- package/src/tui.tsx +337 -0
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prevalentware/opencode-loop-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode loop plugin that adds Claude Code-style /loop recurring prompts: a persistent scheduler that re-injects an instruction into a session on an interval while it is idle.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"opencode",
|
|
7
|
+
"opencode-plugin",
|
|
8
|
+
"opencode loop plugin",
|
|
9
|
+
"opencode recurring tasks",
|
|
10
|
+
"opencode scheduler",
|
|
11
|
+
"loop",
|
|
12
|
+
"recurring",
|
|
13
|
+
"slash-command",
|
|
14
|
+
"coding-agent",
|
|
15
|
+
"ai-agent",
|
|
16
|
+
"developer-tools",
|
|
17
|
+
"agent",
|
|
18
|
+
"tui"
|
|
19
|
+
],
|
|
20
|
+
"homepage": "https://github.com/prevalentWare/opencode-loop-plugin#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/prevalentWare/opencode-loop-plugin/issues"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/prevalentWare/opencode-loop-plugin.git"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"author": "Prevalentware",
|
|
30
|
+
"type": "module",
|
|
31
|
+
"exports": {
|
|
32
|
+
"./server": {
|
|
33
|
+
"import": "./dist/server.js"
|
|
34
|
+
},
|
|
35
|
+
"./tui": {
|
|
36
|
+
"import": "./src/tui.tsx"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"src/tui.tsx",
|
|
42
|
+
"LICENSE",
|
|
43
|
+
"README.md"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"clean": "rm -rf dist",
|
|
47
|
+
"build": "bun run clean && bun build ./src/server.ts --outdir ./dist --target bun --external @opencode-ai/plugin --external effect --external zod",
|
|
48
|
+
"ci:version": "bun scripts/resolve-ci-version.ts",
|
|
49
|
+
"lint": "eslint .",
|
|
50
|
+
"pack:dry-run": "npm pack --dry-run",
|
|
51
|
+
"test": "bun test",
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"prepublishOnly": "bun run test && bun run build"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@opencode-ai/plugin": "^1.17.1",
|
|
57
|
+
"effect": "^3.21.2",
|
|
58
|
+
"zod": "4.1.8"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@eslint/js": "^10.0.1",
|
|
62
|
+
"@opentui/core": "^0.4.0",
|
|
63
|
+
"@opentui/solid": "^0.4.0",
|
|
64
|
+
"@types/bun": "^1.3.13",
|
|
65
|
+
"eslint": "^10.3.0",
|
|
66
|
+
"solid-js": "1.9.12",
|
|
67
|
+
"typescript": "^6.0.3",
|
|
68
|
+
"typescript-eslint": "^8.59.2"
|
|
69
|
+
},
|
|
70
|
+
"engines": {
|
|
71
|
+
"opencode": ">=1.17.1"
|
|
72
|
+
},
|
|
73
|
+
"publishConfig": {
|
|
74
|
+
"access": "public"
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { TuiCommand, TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
3
|
+
import { createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
|
4
|
+
|
|
5
|
+
type LoopSnapshot = {
|
|
6
|
+
id: string
|
|
7
|
+
sessionID: string
|
|
8
|
+
prompt: string
|
|
9
|
+
mode: "interval" | "dynamic"
|
|
10
|
+
intervalMs: number | null
|
|
11
|
+
status: "active" | "paused" | "stopped" | "completed"
|
|
12
|
+
createdAt: number
|
|
13
|
+
updatedAt: number
|
|
14
|
+
nextRunAt: number | null
|
|
15
|
+
lastRunAt: number | null
|
|
16
|
+
lastResult: string | null
|
|
17
|
+
lastError: string | null
|
|
18
|
+
lastReason: string | null
|
|
19
|
+
runCount: number
|
|
20
|
+
maxRuns: number | null
|
|
21
|
+
agent: string | null
|
|
22
|
+
stopReason: string | null
|
|
23
|
+
sampledAt: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type LoopToolPart = {
|
|
27
|
+
type: string
|
|
28
|
+
tool?: string
|
|
29
|
+
state?: {
|
|
30
|
+
status?: string
|
|
31
|
+
output?: string
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type SessionMessage = {
|
|
36
|
+
id: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const LOOP_TOOLS = ["create_loop", "list_loops", "stop_loop", "pause_loop", "resume_loop", "run_loop", "schedule_next_run", "clear_loops"]
|
|
40
|
+
|
|
41
|
+
const loopCache = new Map<string, LoopSnapshot[]>()
|
|
42
|
+
|
|
43
|
+
function loopSnapshotKey(sessionID: string) {
|
|
44
|
+
return `loop-mode.snapshot.${sessionID}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
48
|
+
return typeof value === "object" && value !== null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isLoopSnapshot(value: unknown): value is LoopSnapshot {
|
|
52
|
+
if (!isRecord(value)) return false
|
|
53
|
+
if (typeof value.id !== "string") return false
|
|
54
|
+
if (typeof value.sessionID !== "string") return false
|
|
55
|
+
if (typeof value.prompt !== "string") return false
|
|
56
|
+
if (!["interval", "dynamic"].includes(String(value.mode))) return false
|
|
57
|
+
if (value.intervalMs !== null && typeof value.intervalMs !== "number") return false
|
|
58
|
+
if (!["active", "paused", "stopped", "completed"].includes(String(value.status))) return false
|
|
59
|
+
if (typeof value.createdAt !== "number") return false
|
|
60
|
+
if (typeof value.updatedAt !== "number") return false
|
|
61
|
+
if (value.nextRunAt !== null && typeof value.nextRunAt !== "number") return false
|
|
62
|
+
if (typeof value.runCount !== "number") return false
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isLoopList(value: unknown): value is LoopSnapshot[] {
|
|
67
|
+
return Array.isArray(value) && value.every(isLoopSnapshot)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cachedLoops(api: TuiPluginApi, sessionID: string) {
|
|
71
|
+
const memory = loopCache.get(sessionID)
|
|
72
|
+
if (memory) return memory
|
|
73
|
+
const persisted = api.kv?.get(loopSnapshotKey(sessionID), null)
|
|
74
|
+
return isLoopList(persisted) ? persisted : []
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function cacheLoops(api: TuiPluginApi, sessionID: string, loops: LoopSnapshot[]) {
|
|
78
|
+
loopCache.set(sessionID, loops)
|
|
79
|
+
api.kv?.set(loopSnapshotKey(sessionID), loops)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseLoopToolOutput(part: LoopToolPart): LoopSnapshot[] | undefined {
|
|
83
|
+
if (part.type !== "tool") return undefined
|
|
84
|
+
if (!LOOP_TOOLS.includes(part.tool ?? "")) return undefined
|
|
85
|
+
if (part.state?.status !== "completed") return undefined
|
|
86
|
+
if (typeof part.state.output !== "string") return undefined
|
|
87
|
+
try {
|
|
88
|
+
const parsed: unknown = JSON.parse(part.state.output)
|
|
89
|
+
if (!isRecord(parsed)) return undefined
|
|
90
|
+
return isLoopList(parsed.loops) ? parsed.loops : undefined
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function loopsFromSession(api: TuiPluginApi, sessionID: string): LoopSnapshot[] {
|
|
97
|
+
const messages = [...api.state.session.messages(sessionID)] as SessionMessage[]
|
|
98
|
+
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
|
99
|
+
const message = messages[messageIndex]
|
|
100
|
+
if (!message) continue
|
|
101
|
+
const parts = [...api.state.part(message.id)].reverse() as LoopToolPart[]
|
|
102
|
+
for (const part of parts) {
|
|
103
|
+
const loops = parseLoopToolOutput(part)
|
|
104
|
+
if (loops !== undefined) {
|
|
105
|
+
cacheLoops(api, sessionID, loops)
|
|
106
|
+
return loops
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return cachedLoops(api, sessionID)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function formatInterval(ms: number | null) {
|
|
114
|
+
if (ms == null) return "dynamic"
|
|
115
|
+
const units: [number, string][] = [
|
|
116
|
+
[86_400_000, "d"],
|
|
117
|
+
[3_600_000, "h"],
|
|
118
|
+
[60_000, "m"],
|
|
119
|
+
[1000, "s"],
|
|
120
|
+
]
|
|
121
|
+
for (const [size, suffix] of units) {
|
|
122
|
+
if (ms >= size && ms % size === 0) return `${ms / size}${suffix}`
|
|
123
|
+
}
|
|
124
|
+
return `${Math.round(ms / 1000)}s`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function formatCountdown(msFromNow: number) {
|
|
128
|
+
const total = Math.max(0, Math.ceil(msFromNow / 1000))
|
|
129
|
+
const hours = Math.floor(total / 3600)
|
|
130
|
+
const minutes = Math.floor((total % 3600) / 60)
|
|
131
|
+
const seconds = total % 60
|
|
132
|
+
if (hours > 0) return `${hours}h${String(minutes).padStart(2, "0")}m`
|
|
133
|
+
if (minutes > 0) return `${minutes}m${String(seconds).padStart(2, "0")}s`
|
|
134
|
+
return `${seconds}s`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function loopLine(loop: LoopSnapshot, nowMs: number) {
|
|
138
|
+
const cadence = loop.mode === "interval" ? `every ${formatInterval(loop.intervalMs)}` : "dynamic"
|
|
139
|
+
const runs = `runs ${loop.runCount}${loop.maxRuns == null ? "" : `/${loop.maxRuns}`}`
|
|
140
|
+
let next = ""
|
|
141
|
+
if (loop.status === "active") {
|
|
142
|
+
next = loop.nextRunAt != null ? `, next in ${formatCountdown(loop.nextRunAt - nowMs)}` : ", awaiting schedule"
|
|
143
|
+
}
|
|
144
|
+
return `${loop.id} ${cadence}, ${runs}${next}`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function currentSessionID(api: TuiPluginApi) {
|
|
148
|
+
const route = api.route.current
|
|
149
|
+
if (route.name !== "session") return undefined
|
|
150
|
+
const sessionID = route.params?.sessionID
|
|
151
|
+
return typeof sessionID === "string" ? sessionID : undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function toast(api: TuiPluginApi, message: string, variant: "info" | "success" | "warning" | "error" = "info") {
|
|
155
|
+
api.ui.toast({ title: "Loop", message, variant, duration: 2500 })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function sendLoopPrompt(api: TuiPluginApi, sessionID: string, text: string) {
|
|
159
|
+
await api.client.session.promptAsync({
|
|
160
|
+
sessionID,
|
|
161
|
+
parts: [{ type: "text", text }],
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function actionOption(api: TuiPluginApi, sessionID: string, title: string, value: string, description: string, prompt: string) {
|
|
166
|
+
return {
|
|
167
|
+
title,
|
|
168
|
+
value,
|
|
169
|
+
description,
|
|
170
|
+
onSelect: () => {
|
|
171
|
+
void sendLoopPrompt(api, sessionID, prompt)
|
|
172
|
+
.then(() => api.ui.dialog.clear())
|
|
173
|
+
.catch((error) => toast(api, error instanceof Error ? error.message : String(error), "error"))
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function showSummary(api: TuiPluginApi, sessionID: string, loops: LoopSnapshot[]) {
|
|
179
|
+
const DialogSelect = api.ui.DialogSelect
|
|
180
|
+
const now = Date.now()
|
|
181
|
+
const open = loops.filter((loop) => loop.status === "active" || loop.status === "paused")
|
|
182
|
+
const options = [
|
|
183
|
+
actionOption(api, sessionID, "Refresh", "refresh", "Ask the agent to list the current loops", "Call list_loops for this session and report each loop briefly."),
|
|
184
|
+
...open.flatMap((loop) => [
|
|
185
|
+
...(loop.status === "active"
|
|
186
|
+
? [
|
|
187
|
+
actionOption(
|
|
188
|
+
api,
|
|
189
|
+
sessionID,
|
|
190
|
+
`Run ${loop.id} now`,
|
|
191
|
+
`run.${loop.id}`,
|
|
192
|
+
loopLine(loop, now),
|
|
193
|
+
`Call run_loop with loop_id "${loop.id}" and report the result briefly.`,
|
|
194
|
+
),
|
|
195
|
+
actionOption(
|
|
196
|
+
api,
|
|
197
|
+
sessionID,
|
|
198
|
+
`Pause ${loop.id}`,
|
|
199
|
+
`pause.${loop.id}`,
|
|
200
|
+
loopLine(loop, now),
|
|
201
|
+
`Call pause_loop with loop_id "${loop.id}" and report the result briefly.`,
|
|
202
|
+
),
|
|
203
|
+
]
|
|
204
|
+
: [
|
|
205
|
+
actionOption(
|
|
206
|
+
api,
|
|
207
|
+
sessionID,
|
|
208
|
+
`Resume ${loop.id}`,
|
|
209
|
+
`resume.${loop.id}`,
|
|
210
|
+
loopLine(loop, now),
|
|
211
|
+
`Call resume_loop with loop_id "${loop.id}" and report the result briefly.`,
|
|
212
|
+
),
|
|
213
|
+
]),
|
|
214
|
+
actionOption(
|
|
215
|
+
api,
|
|
216
|
+
sessionID,
|
|
217
|
+
`Stop ${loop.id}`,
|
|
218
|
+
`stop.${loop.id}`,
|
|
219
|
+
loopLine(loop, now),
|
|
220
|
+
`Call stop_loop with loop_id "${loop.id}" and report the result briefly.`,
|
|
221
|
+
),
|
|
222
|
+
]),
|
|
223
|
+
...(loops.some((loop) => loop.status === "stopped" || loop.status === "completed")
|
|
224
|
+
? [actionOption(api, sessionID, "Clear closed loops", "clear", "Delete stopped and completed loops", "Call clear_loops for this session and report how many loops were removed.")]
|
|
225
|
+
: []),
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
api.ui.dialog.setSize("large")
|
|
229
|
+
api.ui.dialog.replace(() =>
|
|
230
|
+
DialogSelect({
|
|
231
|
+
title: "Loops",
|
|
232
|
+
placeholder: open.length === 0 ? "No open loops in this session." : open.map((loop) => loopLine(loop, now)).join("\n"),
|
|
233
|
+
options,
|
|
234
|
+
onSelect(option) {
|
|
235
|
+
option.onSelect?.()
|
|
236
|
+
},
|
|
237
|
+
}),
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function LoopSidebar(props: { api: TuiPluginApi; sessionID: string }) {
|
|
242
|
+
const theme = () => props.api.theme.current
|
|
243
|
+
const [nowMs, setNowMs] = createSignal(Date.now())
|
|
244
|
+
const timer = setInterval(() => setNowMs(Date.now()), 1000)
|
|
245
|
+
onCleanup(() => clearInterval(timer))
|
|
246
|
+
const loops = createMemo(() => {
|
|
247
|
+
props.api.state.session.messages(props.sessionID)
|
|
248
|
+
return loopsFromSession(props.api, props.sessionID)
|
|
249
|
+
})
|
|
250
|
+
const open = createMemo(() => loops().filter((loop) => loop.status === "active" || loop.status === "paused"))
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<Show when={open().length > 0}>
|
|
254
|
+
<box>
|
|
255
|
+
<text fg={theme().text}>
|
|
256
|
+
<b>Loops</b>
|
|
257
|
+
</text>
|
|
258
|
+
<For each={open()}>
|
|
259
|
+
{(loop) => (
|
|
260
|
+
<text fg={loop.status === "active" ? theme().textMuted : theme().textMuted}>
|
|
261
|
+
{loop.status === "paused" ? "⏸ " : ""}
|
|
262
|
+
{loopLine(loop, nowMs())}
|
|
263
|
+
</text>
|
|
264
|
+
)}
|
|
265
|
+
</For>
|
|
266
|
+
</box>
|
|
267
|
+
</Show>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function registerLoopCommand(api: TuiPluginApi, command: TuiCommand) {
|
|
272
|
+
const modern = api as TuiPluginApi & {
|
|
273
|
+
keymap?: {
|
|
274
|
+
registerLayer?: (layer: {
|
|
275
|
+
commands: {
|
|
276
|
+
namespace: string
|
|
277
|
+
name: string
|
|
278
|
+
title: string
|
|
279
|
+
desc?: string
|
|
280
|
+
category?: string
|
|
281
|
+
run?: () => void
|
|
282
|
+
}[]
|
|
283
|
+
bindings?: unknown[]
|
|
284
|
+
}) => () => void
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (modern.keymap?.registerLayer) {
|
|
288
|
+
modern.keymap.registerLayer({
|
|
289
|
+
commands: [
|
|
290
|
+
{
|
|
291
|
+
namespace: "palette",
|
|
292
|
+
name: command.value,
|
|
293
|
+
title: command.title,
|
|
294
|
+
desc: command.description,
|
|
295
|
+
category: command.category,
|
|
296
|
+
run: command.onSelect,
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
bindings: [],
|
|
300
|
+
})
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
api.command?.register(() => [command])
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const tui: TuiPlugin = async (api) => {
|
|
307
|
+
api.slots.register({
|
|
308
|
+
order: 126,
|
|
309
|
+
slots: {
|
|
310
|
+
sidebar_content(_ctx, props) {
|
|
311
|
+
return <LoopSidebar api={api} sessionID={props.session_id} />
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
registerLoopCommand(api, {
|
|
317
|
+
title: "Loops",
|
|
318
|
+
value: "loop.show",
|
|
319
|
+
category: "Loop",
|
|
320
|
+
description: "View, run, pause, resume, or stop the recurring loops in this session",
|
|
321
|
+
onSelect: () => {
|
|
322
|
+
const sessionID = currentSessionID(api)
|
|
323
|
+
if (!sessionID) {
|
|
324
|
+
toast(api, "Open a session before viewing loops.", "warning")
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
showSummary(api, sessionID, loopsFromSession(api, sessionID))
|
|
328
|
+
},
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const plugin: TuiPluginModule = {
|
|
333
|
+
id: "local.loop-mode.tui",
|
|
334
|
+
tui,
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export default plugin
|