@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/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