From 8b435296d6b1c35cd8d53421ed50eab709a903b3 Mon Sep 17 00:00:00 2001 From: Charlene Leong Date: Sun, 30 Aug 2026 19:53:49 -0700 Subject: [PATCH 1/4] feat: add goal-loop example plugin --- packages/plugin/src/examples/goal-loop.ts | 95 +++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 packages/plugin/src/examples/goal-loop.ts diff --git a/packages/plugin/src/examples/goal-loop.ts b/packages/plugin/src/examples/goal-loop.ts new file mode 100644 index 000000000000..cb870d25bc09 --- /dev/null +++ b/packages/plugin/src/examples/goal-loop.ts @@ -0,0 +1,95 @@ +import type { Plugin } from "@opencode-ai/plugin" +import { tool } from "@opencode-ai/plugin" +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs" +import { join } from "path" + +type GoalState = { + condition: string + active: boolean + turns: number + startedAt: string +} + +type LoopState = { + id: string + prompt: string + interval: string + createdAt: string +} + +const HARNESS_DIR = ".opencode/harness" + +function readJson(file: string, fallback: T): T { + const p = join(HARNESS_DIR, file) + if (!existsSync(p)) return fallback + return JSON.parse(readFileSync(p, "utf-8")) as T +} + +function writeJson(file: string, data: unknown) { + const dir = join(HARNESS_DIR) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + writeFileSync(join(HARNESS_DIR, file), JSON.stringify(data, null, 2), "utf-8") +} + +export const GoalLoopPlugin: Plugin = async ({ directory }) => ({ + tool: { + goal: tool({ + description: + "Set, check, or clear an autonomous goal. goal({action:'set',condition:'...'}) to set. goal({action:'status'}) to check. goal({action:'clear'}) to clear.", + args: { + action: tool.schema.enum(["set", "status", "clear"]), + condition: tool.schema.string().optional(), + }, + execute(args) { + if (args.action === "clear") { + writeJson("goal.json", { active: false }) + return "Goal cleared." + } + if (args.action === "status") { + const g = readJson("goal.json", null) + if (!g?.active) return "No active goal." + return `Goal: "${g.condition}" | Turns: ${g.turns} | Since: ${g.startedAt}` + } + if (!args.condition) return "Provide a condition." + const goal: GoalState = { + condition: args.condition, + active: true, + turns: 0, + startedAt: new Date().toISOString(), + } + writeJson("goal.json", goal) + return `Goal set: "${goal.condition}". Work toward it. Call goal({action:'clear'}) when done.` + }, + }), + + loop: tool({ + description: + "Schedule a recurring prompt. loop({action:'add',prompt:'...',interval:'5m'}) to add. loop({action:'list'}) to list. loop({action:'remove',loopId:'...'}) to remove.", + args: { + action: tool.schema.enum(["add", "list", "remove"]), + prompt: tool.schema.string().optional(), + interval: tool.schema.string().optional(), + loopId: tool.schema.string().optional(), + }, + execute(args) { + if (args.action === "list") { + const loops = readJson("loops.json", []) + if (!loops.length) return "No active loops." + return loops.map((l) => `[${l.id}] ${l.interval}: "${l.prompt}"`).join("\n") + } + if (args.action === "remove") { + const loops = readJson("loops.json", []).filter((l) => l.id !== args.loopId) + writeJson("loops.json", loops) + return `Removed loop ${args.loopId}.` + } + if (!args.prompt) return "Provide a prompt." + const id = Math.random().toString(36).substring(2, 8) + const interval = args.interval || "10m" + const loops = readJson("loops.json", []) + loops.push({ id, prompt: args.prompt, interval, createdAt: new Date().toISOString() }) + writeJson("loops.json", loops) + return `Loop [${id}] scheduled every ${interval}: "${args.prompt}"` + }, + }), + }, +}) From 04ae22606aa5a124ad1bd1c67b5bc46d2204ccfe Mon Sep 17 00:00:00 2001 From: Charlene Leong Date: Sun, 30 Aug 2026 20:31:56 -0700 Subject: [PATCH 2/4] test: add goal-loop example plugin tests --- .../plugin/src/examples/goal-loop.test.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 packages/plugin/src/examples/goal-loop.test.ts diff --git a/packages/plugin/src/examples/goal-loop.test.ts b/packages/plugin/src/examples/goal-loop.test.ts new file mode 100644 index 000000000000..68c6b44ee88a --- /dev/null +++ b/packages/plugin/src/examples/goal-loop.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test" +import fs from "fs/promises" +import path from "path" +import os from "os" + +let tmpdir: string + +function makeTool(fn) { + return { execute: fn } +} + +mock.module("@opencode-ai/plugin", () => ({ + tool: makeTool, +})) + +const GoalLoopPlugin = (await import("./goal-loop.js")).default + +function createTestPlugin() { + const hooks = GoalLoopPlugin.server({ directory: tmpdir, project: null, user: null }) + return hooks.tool +} + +beforeEach(async () => { + tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), "goal-loop-test-")) +}) + +afterEach(async () => { + await fs.rm(tmpdir, { recursive: true, force: true }) +}) + +describe("goal tool", () => { + test("set and clear round-trips", async () => { + const { goal } = createTestPlugin() + const result = await goal.execute({ action: "set", condition: "say hello" }) + expect(result).toContain("say hello") + + const state = JSON.parse(await fs.readFile(path.join(tmpdir, ".opencode/harness/goal.json"), "utf-8")) + expect(state.active).toBe(true) + expect(state.condition).toBe("say hello") + expect(state.turns).toBe(0) + + const clear = await goal.execute({ action: "clear" }) + expect(clear).toBe("Goal cleared.") + }) + + test("status shows active goal", async () => { + const { goal } = createTestPlugin() + await goal.execute({ action: "set", condition: "check tests" }) + const status = await goal.execute({ action: "status" }) + expect(status).toContain("check tests") + expect(status).toContain("Turns: 0") + }) + + test("status when no goal", async () => { + const { goal } = createTestPlugin() + const status = await goal.execute({ action: "status" }) + expect(status).toBe("No active goal.") + }) + + test("set without condition fails", async () => { + const { goal } = createTestPlugin() + const result = await goal.execute({ action: "set" }) + expect(result).toBe("Provide a condition.") + }) +}) + +describe("loop tool", () => { + test("add and list round-trips", async () => { + const { loop } = createTestPlugin() + const result = await loop.execute({ action: "add", prompt: "check status", interval: "5m" }) + expect(result).toContain("check status") + expect(result).toContain("5m") + + const list = await loop.execute({ action: "list" }) + expect(list).toContain("check status") + }) + + test("remove loop", async () => { + const { loop } = createTestPlugin() + await loop.execute({ action: "add", prompt: "check status", interval: "5m" }) + const list = await loop.execute({ action: "list" }) + const id = list.match(/\[(\w+)\]/)?.[1] + expect(id).toBeTruthy() + + const removed = await loop.execute({ action: "remove", loopId: id }) + expect(removed).toContain(id) + + const afterRemove = await loop.execute({ action: "list" }) + expect(afterRemove).toBe("No active loops.") + }) + + test("list when no loops", async () => { + const { loop } = createTestPlugin() + const list = await loop.execute({ action: "list" }) + expect(list).toBe("No active loops.") + }) + + test("add without prompt fails", async () => { + const { loop } = createTestPlugin() + const result = await loop.execute({ action: "add" }) + expect(result).toBe("Provide a prompt.") + }) +}) From 3b1b51b82798c8e1f8e0aa5eb6202519bff8809a Mon Sep 17 00:00:00 2001 From: Charlene Leong Date: Wed, 2 Sep 2026 13:30:41 +1000 Subject: [PATCH 3/4] feat: add maxTurns and oneTaskPerTurn to goal tool --- packages/plugin/src/examples/goal-loop.ts | 182 ++++++++++++---------- 1 file changed, 104 insertions(+), 78 deletions(-) diff --git a/packages/plugin/src/examples/goal-loop.ts b/packages/plugin/src/examples/goal-loop.ts index cb870d25bc09..8e1e37496c80 100644 --- a/packages/plugin/src/examples/goal-loop.ts +++ b/packages/plugin/src/examples/goal-loop.ts @@ -1,95 +1,121 @@ -import type { Plugin } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin" import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs" import { join } from "path" -type GoalState = { - condition: string - active: boolean - turns: number - startedAt: string -} +const HARNESS_DIR = ".opencode/harness" -type LoopState = { - id: string - prompt: string - interval: string - createdAt: string +function ensureDir(dir: string) { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) } -const HARNESS_DIR = ".opencode/harness" - function readJson(file: string, fallback: T): T { - const p = join(HARNESS_DIR, file) - if (!existsSync(p)) return fallback - return JSON.parse(readFileSync(p, "utf-8")) as T + try { + const p = join(HARNESS_DIR, file) + if (existsSync(p)) return JSON.parse(readFileSync(p, "utf-8")) + } catch {} + return fallback } function writeJson(file: string, data: unknown) { - const dir = join(HARNESS_DIR) - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + ensureDir(HARNESS_DIR) writeFileSync(join(HARNESS_DIR, file), JSON.stringify(data, null, 2), "utf-8") } -export const GoalLoopPlugin: Plugin = async ({ directory }) => ({ - tool: { - goal: tool({ - description: - "Set, check, or clear an autonomous goal. goal({action:'set',condition:'...'}) to set. goal({action:'status'}) to check. goal({action:'clear'}) to clear.", - args: { - action: tool.schema.enum(["set", "status", "clear"]), - condition: tool.schema.string().optional(), - }, - execute(args) { - if (args.action === "clear") { - writeJson("goal.json", { active: false }) - return "Goal cleared." - } - if (args.action === "status") { - const g = readJson("goal.json", null) - if (!g?.active) return "No active goal." - return `Goal: "${g.condition}" | Turns: ${g.turns} | Since: ${g.startedAt}` - } - if (!args.condition) return "Provide a condition." - const goal: GoalState = { - condition: args.condition, - active: true, - turns: 0, - startedAt: new Date().toISOString(), - } - writeJson("goal.json", goal) - return `Goal set: "${goal.condition}". Work toward it. Call goal({action:'clear'}) when done.` - }, - }), +export default { + id: "goal-loop", + server: async ({ directory }: { directory: string }) => { + return { + tool: { + goal: tool({ + description: + "Set, check, or clear an autonomous goal. goal({action:'set',condition:'...'}) to set. goal({action:'status'}) to check. goal({action:'clear'}) to clear.", + args: { + action: tool.schema.enum(["set", "status", "clear"]), + condition: tool.schema.string().optional(), + maxTurns: tool.schema.number().optional(), + oneTaskPerTurn: tool.schema.boolean().optional(), + }, + async execute(args: { + action: "set" | "status" | "clear" + condition?: string + maxTurns?: number + oneTaskPerTurn?: boolean + }) { + if (args.action === "clear") { + writeJson("goal.json", { active: false }) + return "Goal cleared." + } + if (args.action === "status") { + const g = readJson<{ + condition: string + active: boolean + turns: number + maxTurns?: number + oneTaskPerTurn?: boolean + startedAt: string + } | null>("goal.json", null) + if (!g || !g.active) return "No active goal." + const limit = g.maxTurns ? ` | Max: ${g.maxTurns}` : "" + const taskMode = g.oneTaskPerTurn ? " | One task/turn" : "" + return `Goal: "${g.condition}" | Turns: ${g.turns || 0}${limit}${taskMode} | Since: ${g.startedAt}` + } + if (!args.condition) return "Provide a condition." + const goal = { + condition: args.condition, + active: true, + turns: 0, + maxTurns: args.maxTurns || null, + oneTaskPerTurn: args.oneTaskPerTurn || false, + startedAt: new Date().toISOString(), + } + writeJson("goal.json", goal) + const extras: string[] = [] + if (goal.maxTurns) extras.push(`max ${goal.maxTurns} turns`) + if (goal.oneTaskPerTurn) extras.push("one task per turn") + const hint = extras.length ? ` (${extras.join(", ")})` : "" + return `Goal set: "${goal.condition}"${hint}. Work toward it. Call goal({action:'clear'}) when done.` + }, + }), - loop: tool({ - description: - "Schedule a recurring prompt. loop({action:'add',prompt:'...',interval:'5m'}) to add. loop({action:'list'}) to list. loop({action:'remove',loopId:'...'}) to remove.", - args: { - action: tool.schema.enum(["add", "list", "remove"]), - prompt: tool.schema.string().optional(), - interval: tool.schema.string().optional(), - loopId: tool.schema.string().optional(), + loop: tool({ + description: + "Schedule a recurring prompt. loop({action:'add',prompt:'...',interval:'5m'}) to add. loop({action:'list'}) to list. loop({action:'remove',loopId:'...'}) to remove.", + args: { + action: tool.schema.enum(["add", "list", "remove"]), + prompt: tool.schema.string().optional(), + interval: tool.schema.string().optional(), + loopId: tool.schema.string().optional(), + }, + async execute(args: { + action: "add" | "list" | "remove" + prompt?: string + interval?: string + loopId?: string + }) { + if (args.action === "list") { + const loops = readJson>("loops.json", []) + if (!loops.length) return "No active loops." + return loops.map((l) => `[${l.id}] ${l.interval}: "${l.prompt}"`).join("\n") + } + if (args.action === "remove") { + let loops = readJson>("loops.json", []) + loops = loops.filter((l) => l.id !== args.loopId) + writeJson("loops.json", loops) + return `Removed loop ${args.loopId}.` + } + if (!args.prompt) return "Provide a prompt." + const id = Math.random().toString(36).substring(2, 8) + const interval = args.interval || "10m" + const loops = readJson>( + "loops.json", + [], + ) + loops.push({ id, prompt: args.prompt, interval, createdAt: new Date().toISOString() }) + writeJson("loops.json", loops) + return `Loop [${id}] scheduled every ${interval}: "${args.prompt}"` + }, + }), }, - execute(args) { - if (args.action === "list") { - const loops = readJson("loops.json", []) - if (!loops.length) return "No active loops." - return loops.map((l) => `[${l.id}] ${l.interval}: "${l.prompt}"`).join("\n") - } - if (args.action === "remove") { - const loops = readJson("loops.json", []).filter((l) => l.id !== args.loopId) - writeJson("loops.json", loops) - return `Removed loop ${args.loopId}.` - } - if (!args.prompt) return "Provide a prompt." - const id = Math.random().toString(36).substring(2, 8) - const interval = args.interval || "10m" - const loops = readJson("loops.json", []) - loops.push({ id, prompt: args.prompt, interval, createdAt: new Date().toISOString() }) - writeJson("loops.json", loops) - return `Loop [${id}] scheduled every ${interval}: "${args.prompt}"` - }, - }), + } }, -}) +} From 098236b3f18bf0ebf7b7d397b6f4eada71010497 Mon Sep 17 00:00:00 2001 From: Charlene Leong Date: Wed, 2 Sep 2026 13:31:15 +1000 Subject: [PATCH 4/4] test: add maxTurns and oneTaskPerTurn tests --- .../plugin/src/examples/goal-loop.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/plugin/src/examples/goal-loop.test.ts b/packages/plugin/src/examples/goal-loop.test.ts index 68c6b44ee88a..f61ad4b8c4e9 100644 --- a/packages/plugin/src/examples/goal-loop.test.ts +++ b/packages/plugin/src/examples/goal-loop.test.ts @@ -62,6 +62,30 @@ describe("goal tool", () => { const result = await goal.execute({ action: "set" }) expect(result).toBe("Provide a condition.") }) + + test("set with maxTurns", async () => { + const { goal } = createTestPlugin() + const result = await goal.execute({ action: "set", condition: "fix bug", maxTurns: 10 }) + expect(result).toContain("max 10 turns") + + const state = JSON.parse(await fs.readFile(path.join(tmpdir, ".opencode/harness/goal.json"), "utf-8")) + expect(state.maxTurns).toBe(10) + + const status = await goal.execute({ action: "status" }) + expect(status).toContain("Max: 10") + }) + + test("set with oneTaskPerTurn", async () => { + const { goal } = createTestPlugin() + const result = await goal.execute({ action: "set", condition: "write tests", oneTaskPerTurn: true }) + expect(result).toContain("one task per turn") + + const state = JSON.parse(await fs.readFile(path.join(tmpdir, ".opencode/harness/goal.json"), "utf-8")) + expect(state.oneTaskPerTurn).toBe(true) + + const status = await goal.execute({ action: "status" }) + expect(status).toContain("One task/turn") + }) }) describe("loop tool", () => {