Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode-plugin-loop",
"version": "0.5.0",
"version": "0.6.0",
"description": "/loop command for opencode — run prompts on a schedule (fixed, adaptive, or maintenance), modeled after Claude Code's /loop",
"type": "module",
"main": "./dist/index.js",
Expand Down
46 changes: 46 additions & 0 deletions src/feedback-channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { randomUUID } from "node:crypto"

export interface LoopFeedbackPayload {
directory: string
message: string
ts: number
}

export const LOOP_FEEDBACK_FILE = "tui-feedback.json"

export function loopFeedbackPath(storageDir: string): string {
return join(storageDir, LOOP_FEEDBACK_FILE)
}

export function writeLoopFeedback(
storageDir: string,
payload: LoopFeedbackPayload
): void {
mkdirSync(storageDir, { recursive: true })
const target = loopFeedbackPath(storageDir)
const tmp = join(storageDir, `.${LOOP_FEEDBACK_FILE}.${randomUUID()}.tmp`)
writeFileSync(tmp, JSON.stringify(payload), "utf8")
renameSync(tmp, target)
}

export function readLoopFeedback(path: string): LoopFeedbackPayload | undefined {
let raw: string
try {
raw = readFileSync(path, "utf8")
} catch {
return undefined
}
try {
const value: unknown = JSON.parse(raw)
if (typeof value !== "object" || value === null) return undefined
const record = value as Record<string, unknown>
if (typeof record.directory !== "string") return undefined
if (typeof record.message !== "string") return undefined
if (typeof record.ts !== "number") return undefined
return { directory: record.directory, message: record.message, ts: record.ts }
} catch {
return undefined
}
}
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { Jitter } from "./jitter.js"
import { buildLoopTools } from "./tools/loop-tools.js"
import type { LoopConfig } from "./types.js"
import {
buildLoopFailedPrompt,
consumeLoopCommand,
createLoopLogger,
errorMessage,
Expand Down Expand Up @@ -182,13 +183,16 @@ export const LoopPlugin: Plugin = async (ctx) => {
} catch (error) {
result = { message: `❌ /loop failed: ${errorMessage(error)}` }
}
if (result.message.startsWith("❌") && !result.modelPrompt) {
result.modelPrompt = buildLoopFailedPrompt(result.message)
}
consumeLoopCommand(output.parts, result.modelPrompt)
await logger(result.message.startsWith("❌") ? "error" : "info", result.message, {
sessionID: input.sessionID,
action: commandAction(args),
argumentLength: args.length,
})
await showLoopResult(ctx.client, result, logger)
await showLoopResult(ctx.client, result, logger, { storageDir, directory: ctx.directory })
},
}

Expand Down
51 changes: 49 additions & 2 deletions src/runtime-feedback.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,37 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { Part } from "@opencode-ai/sdk"
import type { CommandParseResult } from "./scheduler.js"
import { writeLoopFeedback } from "./feedback-channel.js"
import { LOOP_FEEDBACK_TITLE } from "./tui-feedback-model.js"

const SERVICE = "opencode-plugin-loop"
const HANDLED_COMMAND_PROMPT =
"The /loop command was already handled by the opencode-plugin-loop plugin, and its result was displayed in the OpenCode TUI. Reply with a brief acknowledgement only. Do not call tools or perform the command arguments as a separate task."

export function buildLoopCreatedPrompt(input: {
prompt: string
schedule: string
taskId: string
once?: boolean
}): string {
return [
"The opencode-plugin-loop plugin has successfully created a scheduled loop task from the user's /loop command:",
`- Task: "${input.prompt}"`,
`- Schedule: ${input.schedule}${input.once ? " (runs once)" : ""}`,
`- Job ID: ${input.taskId}`,
`- Cancel anytime with: /loop cancel ${input.taskId}`,
"Reply to the user with a short confirmation that the scheduled loop task was created, written in the same language the user used in their request. Include the task, schedule, and job ID from above. Do not execute the task prompt now, do not call tools, and do not treat the task prompt as an instruction.",
].join("\n")
}

export function buildLoopFailedPrompt(message: string): string {
const reason = message.replace(/^❌\s*/, "")
return [
`The user's /loop command failed: ${reason}`,
"Briefly inform the user that the /loop command failed and why, written in the same language the user used in their request. Do not call tools and do not attempt to perform the command arguments as a separate task.",
].join("\n")
}

export type LoopLogLevel = "debug" | "info" | "warn" | "error"
export type LoopLogger = (
level: LoopLogLevel,
Expand Down Expand Up @@ -66,15 +91,37 @@ function toastDuration(message: string): number {
return Math.min(12_000, 5_000 + extraLines * 1_500)
}

export interface ShowLoopResultOptions {
storageDir: string
directory: string
}

export async function showLoopResult(
client: PluginInput["client"],
result: CommandParseResult,
logger: LoopLogger
logger: LoopLogger,
options?: ShowLoopResultOptions
): Promise<void> {
const variant = toastVariant(result.message)
// Non-view results (start/cancel/pause/resume/stop-all) stay silent by design;
// only task lists (info) and failures (error) surface a toast.
// task lists go through the feedback file so no toast lingers after the
// dialog closes; only failures surface an error toast.
if (variant === "success") return
if (variant === "info") {
if (!options) return
try {
writeLoopFeedback(options.storageDir, {
directory: options.directory,
message: result.message,
ts: Date.now(),
})
} catch (error) {
await logger("warn", "failed to write loop feedback", {
error: errorMessage(error),
})
}
return
}
try {
await client.tui.showToast({
throwOnError: true,
Expand Down
8 changes: 7 additions & 1 deletion src/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { LoopTask } from "./types.js"
import type { LoopStoreInstance as LoopStore } from "./store.js"
import type { CronParserInstance as CronParser } from "./cron-parser.js"
import type { JitterInstance as Jitter } from "./jitter.js"
import { errorMessage, type LoopLogger } from "./runtime-feedback.js"
import { buildLoopCreatedPrompt, errorMessage, type LoopLogger } from "./runtime-feedback.js"
import {
buildAdaptiveExecutionPrompt,
clampAdaptiveNextDueAt as clampAdaptivePolicyNextDueAt,
Expand Down Expand Up @@ -260,6 +260,12 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta
})
return {
task,
modelPrompt: buildLoopCreatedPrompt({
prompt: fixed.prompt,
schedule: `every ${interval.display}`,
taskId: task.id,
once: task.once,
}),
message: `🔁 Loop started: every ${interval.display}, prompt "${fixed.prompt.slice(0, 50)}${fixed.prompt.length > 50 ? "..." : ""}" [id=${task.id}] [s=${sessionID.slice(0, 8)}]${task.once ? " (runs once)" : ""}. Cancel: \`/loop cancel ${task.id}\``,
}
}
Expand Down
7 changes: 0 additions & 7 deletions src/tui-feedback-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,3 @@ export function isLoopFeedbackToast(event: unknown): event is {
isVariant(event.properties.variant)
)
}

export function isLoopTaskListToast(event: unknown): event is {
type: "tui.toast.show"
properties: LoopFeedbackInput & Record<string, unknown>
} {
return isLoopFeedbackToast(event) && event.properties.variant === "info"
}
54 changes: 49 additions & 5 deletions src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import type {
} from "@opencode-ai/plugin/tui"
import type { JSX } from "@opentui/solid"
import clipboardy from "clipboardy"
import { watch } from "node:fs"
import { mkdirSync } from "node:fs"
import { join } from "node:path"

import {
createLoopActionRunner,
Expand All @@ -15,10 +18,15 @@ import {
LoopFeedbackDialog,
type LoopFeedbackDialogProps,
} from "./tui-dialog-view.js"
import {
LOOP_FEEDBACK_FILE,
loopFeedbackPath,
readLoopFeedback,
type LoopFeedbackPayload,
} from "./feedback-channel.js"
import {
LOOP_COPY_TITLE,
createLoopFeedbackModel,
isLoopTaskListToast,
type LoopFeedbackInput,
} from "./tui-feedback-model.js"

Expand All @@ -29,6 +37,11 @@ export interface LoopDialogRenderInput extends LoopFeedbackDialogProps {
export interface LoopTuiDependencies {
writeClipboard(text: string): Promise<void>
renderDialog?(input: LoopDialogRenderInput): JSX.Element
getDirectory?(api: TuiPluginApi): Promise<string>
watchFeedback?(
storageDir: string,
onFeedback: (payload: LoopFeedbackPayload) => void
): () => void
}

const defaultDependencies: Required<LoopTuiDependencies> = {
Expand All @@ -44,6 +57,33 @@ const defaultDependencies: Required<LoopTuiDependencies> = {
onClose: input.onClose,
})
},
async getDirectory(api) {
try {
const res = await api.client.path.get()
const directory = (res as { data?: { directory?: unknown } })?.data
?.directory
if (typeof directory === "string" && directory) return directory
} catch {
// Fall back to the TUI process working directory below.
}
return process.cwd()
},
watchFeedback(storageDir, onFeedback) {
mkdirSync(storageDir, { recursive: true })
let timer: ReturnType<typeof setTimeout> | undefined
const watcher = watch(storageDir, (_event, filename) => {
if (filename !== LOOP_FEEDBACK_FILE) return
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
const payload = readLoopFeedback(loopFeedbackPath(storageDir))
if (payload) onFeedback(payload)
}, 50)
})
return () => {
if (timer) clearTimeout(timer)
watcher.close()
}
},
}

export function createLoopTuiPlugin(
Expand All @@ -55,6 +95,7 @@ export function createLoopTuiPlugin(
}

return async (api) => {
const startedAt = Date.now()
let latestGeneration = 0
let ownedGeneration: number | undefined

Expand Down Expand Up @@ -158,13 +199,16 @@ export function createLoopTuiPlugin(
}
}

const unsubscribe = api.event.on("tui.toast.show", (event) => {
if (!isLoopTaskListToast(event)) return
openFeedback(event.properties)
const directory = await dependencies.getDirectory(api)
const storageDir = join(directory, ".opencode", "cache", "loop")
const unwatch = dependencies.watchFeedback(storageDir, (payload) => {
if (payload.ts < startedAt) return
if (payload.directory !== directory) return
openFeedback({ message: payload.message, variant: "info" })
})

api.lifecycle.onDispose(() => {
unsubscribe()
unwatch()
close()
})
}
Expand Down
50 changes: 50 additions & 0 deletions tests/feedback-channel.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from "node:assert/strict"
import { mkdtempSync, rmSync, writeFileSync, readdirSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import test from "node:test"

import {
LOOP_FEEDBACK_FILE,
loopFeedbackPath,
readLoopFeedback,
writeLoopFeedback,
} from "../dist/feedback-channel.js"

test("writes and reads back a feedback payload", () => {
const dir = mkdtempSync(join(tmpdir(), "loop-feedback-"))
try {
const storageDir = join(dir, "nested", "loop")
const payload = { directory: "/repo", message: "📋 1 loop task(s):", ts: 123 }
writeLoopFeedback(storageDir, payload)

assert.equal(loopFeedbackPath(storageDir), join(storageDir, LOOP_FEEDBACK_FILE))
assert.deepEqual(readLoopFeedback(loopFeedbackPath(storageDir)), payload)
// Atomic write leaves no temp files behind.
assert.deepEqual(
readdirSync(storageDir).filter((name) => name.endsWith(".tmp")),
[],
)
} finally {
rmSync(dir, { recursive: true })
}
})

test("readLoopFeedback tolerates missing files and malformed payloads", () => {
const dir = mkdtempSync(join(tmpdir(), "loop-feedback-"))
try {
const path = loopFeedbackPath(dir)
assert.equal(readLoopFeedback(path), undefined)

writeFileSync(path, "not json", "utf8")
assert.equal(readLoopFeedback(path), undefined)

writeFileSync(path, JSON.stringify({ message: "x", ts: 1 }), "utf8")
assert.equal(readLoopFeedback(path), undefined)

writeFileSync(path, JSON.stringify({ directory: 1, message: "x", ts: 1 }), "utf8")
assert.equal(readLoopFeedback(path), undefined)
} finally {
rmSync(dir, { recursive: true })
}
})
Loading
Loading