From c4388583173b25ea0b5ba7c1f1147715fe1e078a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:32:30 +0300 Subject: [PATCH 01/18] Add user-authorized Goal revision bridge --- src/opencode/user-revision.ts | 324 ++++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 src/opencode/user-revision.ts diff --git a/src/opencode/user-revision.ts b/src/opencode/user-revision.ts new file mode 100644 index 0000000..5a14f70 --- /dev/null +++ b/src/opencode/user-revision.ts @@ -0,0 +1,324 @@ +import { tool } from "@opencode-ai/plugin/tool" +import type CorePlugin from "./plugin.js" +import { editGoal } from "../domain/goal.js" +import type { GoalExecutionContext, GoalState, GoalStatus } from "../domain/types.js" +import { GoalStore, GoalStoreConcurrencyError } from "../persistence/store.js" +import { continuationPrompt } from "./prompt.js" + +type PluginInput = Parameters[0] +type PluginHooks = Awaited> + +export type GoalUserRevisionMode = "extend" | "replace" + +interface UserRevisionAuthorization { + sessionID: string + goalID: string + goalRevision: number + userMessageID: string + assistantMessageID?: string + text: string + execution?: GoalExecutionContext + expiresAt: number +} + +const AUTHORIZATION_TTL_MS = 10 * 60_000 +const COMMAND_OUTPUT_TTL_MS = 60_000 +const REVISION_BOUNDARY_TTL_MS = 60_000 +const REVISION_MUTATION_TOOLS = new Set(["write", "edit", "apply_patch", "bash", "todowrite", "task"]) +const CONTINUATION_PREFIX = "Continue working toward the active OpenCode goal.\n\n\n" + +function textFromParts(parts: any[]): string { + return parts.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n") +} + +function userMessageID(event: any, output: any): string | undefined { + if (typeof event?.messageID === "string" && event.messageID) return event.messageID + if (typeof output?.message?.id === "string" && output.message.id) return output.message.id + return undefined +} + +function executionContext(event: any, goal: GoalState): GoalExecutionContext | undefined { + const context: GoalExecutionContext = { + ...(event?.agent ? { agent: event.agent } : {}), + ...(event?.model ? { model: event.model } : {}), + ...(event?.variant ? { variant: event.variant } : {}), + ...(goal.execution?.modelContext ? { modelContext: goal.execution.modelContext } : {}), + } + return Object.keys(context).length ? context : goal.execution +} + +function eligibleRevisionStatus(status: GoalStatus): boolean { + return status === "active" || status === "paused" || status === "blocked" +} + +function goalOwnedContinuation(goal: GoalState, text: string): boolean { + if (!text.startsWith(CONTINUATION_PREFIX)) return false + return text === continuationPrompt(goal) +} + +function isSyntheticHostMessage(output: any): boolean { + if (output?.noReply === true) return true + return Array.isArray(output?.parts) && output.parts.some((part: any) => part?.synthetic === true) +} + +function appendRevisionAdvisory(output: any, goal: GoalState): void { + if (!Array.isArray(output?.parts)) return + output.parts.push({ + type: "text", + synthetic: true, + text: [ + "", + `A persisted OpenCode Goal exists (status=${goal.status}, revision=${goal.revision}).`, + "This is a foreground human message. It does not silently rewrite the Goal contract.", + "If this message materially ADDS required work to the existing Goal, call opencode_goal_revise_from_user with mode=extend before implementing the changed scope.", + "If this message intentionally REPLACES the requested outcome, call opencode_goal_revise_from_user with mode=replace before implementing the changed scope.", + "Do not revise for questions, status/explanation requests, or ordinary steering that already fits the current Goal. Short explicit resume messages are handled separately.", + "The revision tool can consume only this exact latest human message; it accepts no model-authored objective text. A successful revision creates a turn boundary, so end the current assistant turn and let the next Goal-owned turn re-plan and continue.", + "", + ].join("\n"), + }) +} + +export function reviseGoalFromForegroundUser(goal: GoalState, input: { + text: string + mode: GoalUserRevisionMode + execution?: GoalExecutionContext + now?: number +}): GoalState { + if (!eligibleRevisionStatus(goal.status)) { + throw new Error(`Goal status ${goal.status} cannot be revised implicitly; use explicit Goal lifecycle/budget controls.`) + } + const text = input.text.trim() + if (!text) throw new Error("foreground user instruction must not be empty") + const objective = input.mode === "replace" + ? text + : `${goal.objective.trim()}\n\nAdditional user instruction:\n${text}` + const next = editGoal(goal, { + objective, + ...(input.execution ? { execution: input.execution } : {}), + ...(input.now === undefined ? {} : { now: input.now }), + }) + // A material scope revision must force a fresh execution plan. Keeping the + // previous Todo snapshot as stale telemetry is safe for completion, but it is + // counterproductive for a user-driven re-plan because models can keep visually + // anchoring on the old checklist. Historical work/evidence remains in Goal state. + delete next.todoPlan + return next +} + +class UserRevisionAuthorizations { + #bySession = new Map() + + clear(sessionID: string): void { + this.#bySession.delete(sessionID) + } + + capture(goal: GoalState, input: { + userMessageID: string + text: string + execution?: GoalExecutionContext + now?: number + }): void { + const now = input.now ?? Date.now() + this.#bySession.set(goal.sessionID, { + sessionID: goal.sessionID, + goalID: goal.id, + goalRevision: goal.revision, + userMessageID: input.userMessageID, + text: input.text, + ...(input.execution ? { execution: input.execution } : {}), + expiresAt: now + AUTHORIZATION_TTL_MS, + }) + } + + bindAssistant(sessionID: string, parentID: string | undefined, assistantMessageID: string | undefined, now = Date.now()): void { + const current = this.#bySession.get(sessionID) + if (!current || current.expiresAt <= now) { + if (current) this.#bySession.delete(sessionID) + return + } + if (!parentID || !assistantMessageID || current.userMessageID !== parentID) return + current.assistantMessageID = assistantMessageID + } + + match(sessionID: string, assistantMessageID: string | undefined, goal: GoalState, now = Date.now()): UserRevisionAuthorization | null { + const current = this.#bySession.get(sessionID) + if (!current) return null + if (current.expiresAt <= now) { + this.#bySession.delete(sessionID) + return null + } + if (current.goalID !== goal.id || current.goalRevision !== goal.revision) { + this.#bySession.delete(sessionID) + return null + } + if (!assistantMessageID || current.assistantMessageID !== assistantMessageID) return null + return current + } + + consume(sessionID: string, authorization: UserRevisionAuthorization): void { + if (this.#bySession.get(sessionID) === authorization) this.#bySession.delete(sessionID) + } +} + +/** + * Give a foreground human follow-up an explicit, one-shot path into the durable + * Goal contract. The model chooses extend vs replace semantically, but it cannot + * author arbitrary scope: the host persists the exact latest human message that + * directly parented the current assistant turn. + */ +export function installGoalUserRevision(input: PluginInput, hooks: PluginHooks): void { + const commandHook = hooks["command.execute.before"] + const chatHook = hooks["chat.message"] + const eventHook = hooks.event + if (typeof commandHook !== "function" || typeof chatHook !== "function" || typeof eventHook !== "function") return + + const store = new GoalStore(input.directory) + const authorizations = new UserRevisionAuthorizations() + const commandOutputs = new Map() + const revisionBoundaries = new Map() + + function clearExpiredCommandOutput(sessionID: string, now = Date.now()): string | undefined { + const current = commandOutputs.get(sessionID) + if (!current) return undefined + commandOutputs.delete(sessionID) + return current.expiresAt > now ? current.text : undefined + } + + function activeBoundary(sessionID: string, now = Date.now()): { revision: number; expiresAt: number } | undefined { + const boundary = revisionBoundaries.get(sessionID) + if (!boundary) return undefined + if (boundary.expiresAt <= now) { + revisionBoundaries.delete(sessionID) + return undefined + } + return boundary + } + + hooks["command.execute.before"] = async (event: any, output: any) => { + const sessionID = typeof event?.sessionID === "string" ? event.sessionID : "" + if (sessionID) { + authorizations.clear(sessionID) + revisionBoundaries.delete(sessionID) + } + await commandHook(event, output) + if (!sessionID) return + const text = textFromParts(output?.parts ?? []) + if (text) commandOutputs.set(sessionID, { text, expiresAt: Date.now() + COMMAND_OUTPUT_TTL_MS }) + } + + hooks["chat.message"] = async (event: any, output: any) => { + const sessionID = typeof event?.sessionID === "string" ? event.sessionID : "" + if (!sessionID) { + await chatHook(event, output) + return + } + + // A new user/prompt turn is the boundary after a successful revision. Any + // mutation guard from the assistant turn that created the revision is done. + revisionBoundaries.delete(sessionID) + + const text = textFromParts(output?.parts ?? []) + const commandOutput = clearExpiredCommandOutput(sessionID) + if (commandOutput && commandOutput === text) { + authorizations.clear(sessionID) + await chatHook(event, output) + return + } + + let goal: GoalState | null + try { + goal = await store.load(sessionID) + } catch { + // This wrapper is authorization UX, not a second persistence authority. + // Core Goal storage paths still fail closed through their normal hooks. + await chatHook(event, output) + return + } + + const messageID = userMessageID(event, output) + const synthetic = isSyntheticHostMessage(output) + const ownedContinuation = Boolean(goal && goalOwnedContinuation(goal, text)) + authorizations.clear(sessionID) + + if (goal && goal.status !== "completed" && messageID && text.trim() && !synthetic && !ownedContinuation) { + authorizations.capture(goal, { + userMessageID: messageID, + text, + ...(executionContext(event, goal) ? { execution: executionContext(event, goal) } : {}), + }) + if (eligibleRevisionStatus(goal.status)) appendRevisionAdvisory(output, goal) + } + + await chatHook(event, output) + } + + hooks.event = async (inputEvent: any) => { + const event = inputEvent?.event + if (event?.type === "message.updated") { + const info = event?.properties?.info + if (info?.role === "assistant") { + const sessionID = typeof info?.sessionID === "string" ? info.sessionID : "" + const parentID = typeof info?.parentID === "string" ? info.parentID : undefined + const assistantMessageID = typeof info?.id === "string" ? info.id : undefined + if (sessionID) authorizations.bindAssistant(sessionID, parentID, assistantMessageID) + } + } + await eventHook(inputEvent) + } + + const toolExecuteBefore = hooks["tool.execute.before"] + hooks["tool.execute.before"] = async (event: any) => { + const sessionID = typeof event?.sessionID === "string" ? event.sessionID : "" + const boundary = sessionID ? activeBoundary(sessionID) : undefined + if (boundary && REVISION_MUTATION_TOOLS.has(String(event?.tool ?? ""))) { + throw new Error(`Goal revision r${boundary.revision} was just created from the latest user instruction. End this assistant turn now; the next Goal-owned turn will re-plan and continue the revised scope.`) + } + if (typeof toolExecuteBefore === "function") await toolExecuteBefore(event) + } + + hooks.tool ||= {} + hooks.tool.opencode_goal_revise_from_user = tool({ + description: "Promote the exact latest foreground human message into a new revision of the current Goal. Use mode=extend when the user materially adds required work while preserving the existing objective; use mode=replace when the user intentionally replaces the requested outcome. Do not call this for questions, status/explanation requests, short resume messages, or ordinary steering already covered by the current Goal. The host accepts no model-authored objective text: only the one-shot human message that directly parented this assistant turn can be consumed. On success, end this assistant turn; the next Goal-owned turn will rebuild its plan and continue the new revision.", + args: { + mode: tool.schema.string(), + }, + execute: async (args: any, context: any) => { + const sessionID = typeof context?.sessionID === "string" ? context.sessionID : "" + const assistantMessageID = typeof context?.messageID === "string" ? context.messageID : undefined + const mode = args?.mode === "extend" || args?.mode === "replace" ? args.mode as GoalUserRevisionMode : null + if (!sessionID) return "Goal revision rejected: missing session context." + if (!mode) return "Goal revision rejected: mode must be extend or replace." + + for (let attempt = 0; attempt < 2; attempt += 1) { + const goal = await store.load(sessionID) + if (!goal) return "Goal revision rejected: no persisted Goal exists in this session." + const authorization = authorizations.match(sessionID, assistantMessageID, goal) + if (!authorization) { + return "Goal revision rejected: no unconsumed latest foreground human instruction is authorized for this assistant turn." + } + if (!eligibleRevisionStatus(goal.status)) { + authorizations.consume(sessionID, authorization) + return `Goal revision rejected: status ${goal.status} requires explicit Goal lifecycle/budget control and cannot be implicitly reactivated.` + } + + const next = reviseGoalFromForegroundUser(goal, { + text: authorization.text, + mode, + ...(authorization.execution ? { execution: authorization.execution } : {}), + }) + try { + await store.save(next) + authorizations.consume(sessionID, authorization) + revisionBoundaries.set(sessionID, { revision: next.revision, expiresAt: Date.now() + REVISION_BOUNDARY_TTL_MS }) + return `Goal revised from the exact foreground user instruction: r${goal.revision} -> r${next.revision} (${mode}); status is active. Native Todo telemetry was reset so the next Goal-owned turn must build a fresh plan. End this assistant turn now; do not perform more workspace mutations in the stale pre-revision turn.` + } catch (error) { + if (error instanceof GoalStoreConcurrencyError && attempt === 0) continue + throw error + } + } + + return "Goal revision rejected after a concurrent Goal state change; inspect the current Goal before retrying." + }, + }) +} From 61fa0b9e7428198add80b28cf09250fc826abdbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:32:55 +0300 Subject: [PATCH 02/18] Wire foreground user Goal revisions --- src/index.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 88e3d8d..642a207 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import OpenCodeGoalCorePlugin from "./opencode/plugin.js" +import { installGoalUserRevision } from "./opencode/user-revision.js" import { installForeignCommandSteeringGuard } from "./opencode/foreign-command-guard.js" import { enhanceGoalControls } from "./opencode/controls.js" import { installGoalAuditUX } from "./opencode/audit-ux.js" @@ -35,10 +36,14 @@ export default async function OpenCodeGoalPlugin( client: preferSynchronousSessionPrompt(infrastructureTransport.client), } const hooks = await OpenCodeGoalCorePlugin(coreInput, applySemanticVerifierTimeoutDefault(options)) + // Foreground human follow-ups can deliberately revise a persisted Goal, but + // only through a one-shot authorization bound to the exact user message. Put + // this directly above core so the foreign-command guard can still intercept + // plugin bridge traffic before it ever becomes revision authority. + installGoalUserRevision(input, hooks) // OpenCode 1.x may still materialize a plugin-handled slash command as a - // synthetic user/model turn. Install this directly above the core so outer - // safety/deferral wrappers still run, while the core never mistakes that - // command bridge for human Goal steering or a new execution context. + // synthetic user/model turn. Install this above the user-revision bridge so + // foreign command traffic cannot authorize or repin a Goal revision. installForeignCommandSteeringGuard(input, hooks) enhanceGoalControls(input, hooks) installGoalAuditUX(input, hooks) @@ -108,4 +113,5 @@ export * from "./opencode/client-compat.js" export * from "./opencode/lifecycle-ux.js" export * from "./opencode/i18n-ux.js" export * from "./opencode/verifier-defaults.js" -export * from "./opencode/foreign-command-guard.js" +export * from "./opencode/user-revision.js" +export * from "./opencode/foreign-command-guard.js" \ No newline at end of file From 663276df4e66e03572feefd76677fc62c4e6c2e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:33:49 +0300 Subject: [PATCH 03/18] Cover foreground user Goal rebasing --- test/user-revision.test.mjs | 258 ++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 test/user-revision.test.mjs diff --git a/test/user-revision.test.mjs b/test/user-revision.test.mjs new file mode 100644 index 0000000..2ce3f0a --- /dev/null +++ b/test/user-revision.test.mjs @@ -0,0 +1,258 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import OpenCodeGoalPlugin from "../dist/index.js" +import { GoalStore } from "../dist/persistence/store.js" +import { observeTodoPlan } from "../dist/runtime/todo-plan.js" + +async function readOnlyGoal(root) { + const dir = path.join(root, ".opencode", "goals") + const files = (await readdir(dir)).filter((name) => name.endsWith(".json")) + assert.equal(files.length, 1) + return JSON.parse(await readFile(path.join(dir, files[0]), "utf8")) +} + +function fakeClient() { + const prompts = [] + let abortCount = 0 + return { + client: { + session: { + prompt(arg) { + prompts.push(arg) + return Promise.resolve({}) + }, + abort() { + abortCount += 1 + return Promise.resolve(true) + }, + }, + tui: { + showToast() { return Promise.resolve({}) }, + }, + }, + prompts, + get abortCount() { return abortCount }, + } +} + +async function command(hooks, argumentsText, sessionID = "revision-session") { + const output = { parts: [{ type: "text", text: argumentsText }] } + await hooks["command.execute.before"]({ command: "goal", sessionID, arguments: argumentsText }, output) + return output +} + +async function foreground(hooks, text, messageID, sessionID = "revision-session") { + const output = { message: { id: messageID }, parts: [{ type: "text", text }] } + await hooks["chat.message"]( + { sessionID, messageID, agent: "build", model: { providerID: "p", modelID: "m" }, variant: "high" }, + output, + ) + return output +} + +async function beginAssistant(hooks, parentID, assistantMessageID, sessionID = "revision-session") { + await hooks.event({ + event: { + type: "message.updated", + properties: { + info: { + id: assistantMessageID, + sessionID, + parentID, + role: "assistant", + time: { created: Date.now() }, + tokens: { input: 0, output: 0, reasoning: 0 }, + cost: 0, + }, + }, + }, + }) +} + +async function finishAssistant(hooks, parentID, assistantMessageID, sessionID = "revision-session") { + await hooks.event({ + event: { + type: "message.updated", + properties: { + info: { + id: assistantMessageID, + sessionID, + parentID, + role: "assistant", + time: { created: 1, completed: 2 }, + tokens: { input: 2, output: 3, reasoning: 1 }, + cost: 0.001, + }, + }, + }, + }) +} + +test("paused Goal can extend from the exact 100-line foreground user instruction and forces a fresh plan", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-goal-user-revision-")) + try { + const fake = fakeClient() + const hooks = await OpenCodeGoalPlugin({ client: fake.client, directory: root }) + const store = new GoalStore(root) + + await command(hooks, "build the original project") + let goal = await store.load("revision-session") + assert.ok(goal) + goal = observeTodoPlan(goal, [ + { content: "Old plan item", status: "in_progress" }, + { content: "Old verification", status: "pending" }, + ], 200) + await store.save(goal) + const budget = { ...goal.budget } + const usage = { ...goal.usage, seenMessageIDs: [...goal.usage.seenMessageIDs] } + + await command(hooks, "pause") + const lines = Array.from({ length: 100 }, (_, index) => `${index + 1}. yeni gereksinim ${index + 1}`) + const instruction = `şimdi bunları da yap:\n${lines.join("\n")}` + const output = await foreground(hooks, instruction, "human-100") + assert.equal(output.parts[0].text, instruction, "the raw human text must remain untouched") + assert.ok(output.parts.some((part) => part?.synthetic === true && /opencode_goal_revise_from_user/.test(part.text ?? ""))) + + let persisted = await readOnlyGoal(root) + assert.equal(persisted.status, "paused", "ordinary foreground chat alone still must not silently mutate lifecycle state") + assert.equal(persisted.revision, 1) + + await beginAssistant(hooks, "human-100", "assistant-100") + const result = await hooks.tool.opencode_goal_revise_from_user.execute( + { mode: "extend" }, + { sessionID: "revision-session", messageID: "assistant-100", agent: "build" }, + ) + assert.match(result, /r1 -> r2 \(extend\)/) + assert.match(result, /End this assistant turn now/) + + persisted = await readOnlyGoal(root) + assert.equal(persisted.status, "active") + assert.equal(persisted.revision, 2) + assert.equal(persisted.objective, `build the original project\n\nAdditional user instruction:\n${instruction}`) + for (const line of lines) assert.ok(persisted.objective.includes(line), `missing exact user line: ${line}`) + assert.equal(persisted.todoPlan, undefined, "a material user revision must force a fresh native Todo plan") + assert.deepEqual(persisted.budget, budget, "revising scope must not reset execution guards") + assert.equal(persisted.usage.turns, usage.turns) + assert.equal(persisted.usage.tokens, usage.tokens) + assert.equal(persisted.stalledTurns, 0) + assert.equal(persisted.skipNextStallCheck, true, "the intentional revision-boundary turn must not spend the no-progress budget") + + const replay = await hooks.tool.opencode_goal_revise_from_user.execute( + { mode: "extend" }, + { sessionID: "revision-session", messageID: "assistant-100", agent: "build" }, + ) + assert.match(replay, /no unconsumed latest foreground human instruction/i) + + await assert.rejects( + hooks["tool.execute.before"]({ sessionID: "revision-session", callID: "stale-write", tool: "write", args: {} }), + /End this assistant turn now/i, + "the pre-revision assistant turn must not mutate the revised Goal", + ) + + await finishAssistant(hooks, "human-100", "assistant-100") + await hooks.event({ event: { type: "session.idle", properties: { sessionID: "revision-session" } } }) + assert.equal(fake.prompts.length, 1) + assert.match(fake.prompts[0].body.parts[0].text, /Additional user instruction:/) + assert.match(fake.prompts[0].body.parts[0].text, /100\. yeni gereksinim 100/) + + persisted = await readOnlyGoal(root) + assert.equal(persisted.status, "active") + assert.equal(persisted.stalledTurns, 0, "the revision boundary must not become a false no-progress pause") + } finally { + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) + } +}) + +test("replace uses only the exact latest human instruction while ordinary active steering remains same-revision until the tool is called", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-goal-user-replace-")) + try { + const fake = fakeClient() + const hooks = await OpenCodeGoalPlugin({ client: fake.client, directory: root }) + + await command(hooks, "finish the old feature") + const instruction = "eski hedefi bırak; bunun yerine ödeme API'sini bitir ve testlerini çalıştır" + await foreground(hooks, instruction, "human-replace") + + let persisted = await readOnlyGoal(root) + assert.equal(persisted.objective, "finish the old feature") + assert.equal(persisted.revision, 1, "foreground steering is not an implicit contract edit by itself") + + await beginAssistant(hooks, "human-replace", "assistant-replace") + const result = await hooks.tool.opencode_goal_revise_from_user.execute( + { mode: "replace" }, + { sessionID: "revision-session", messageID: "assistant-replace", agent: "build" }, + ) + assert.match(result, /r1 -> r2 \(replace\)/) + + persisted = await readOnlyGoal(root) + assert.equal(persisted.objective, instruction) + assert.equal(persisted.revision, 2) + assert.equal(persisted.status, "active") + } finally { + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) + } +}) + +test("questions do not mutate paused Goals and short natural resume keeps the same revision without revision authorization", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-goal-user-question-")) + try { + const fake = fakeClient() + const hooks = await OpenCodeGoalPlugin({ client: fake.client, directory: root }) + + await command(hooks, "finish the tracked project") + await command(hooks, "pause") + + await foreground(hooks, "neden durdu, bana sadece durumu açıklar mısın?", "human-question") + let persisted = await readOnlyGoal(root) + assert.equal(persisted.status, "paused") + assert.equal(persisted.revision, 1) + assert.equal(persisted.objective, "finish the tracked project") + + await foreground(hooks, "devam et", "human-resume") + persisted = await readOnlyGoal(root) + assert.equal(persisted.status, "active") + assert.equal(persisted.revision, 1, "natural resume continues the same Goal rather than creating a new scope revision") + + await beginAssistant(hooks, "human-resume", "assistant-resume") + const result = await hooks.tool.opencode_goal_revise_from_user.execute( + { mode: "extend" }, + { sessionID: "revision-session", messageID: "assistant-resume", agent: "build" }, + ) + assert.match(result, /no unconsumed latest foreground human instruction/i, "natural resume must not become revision authority") + } finally { + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) + } +}) + +test("budget and provider usage stop states cannot be implicitly bypassed by a foreground revision", async () => { + for (const status of ["budget_limited", "usage_limited"]) { + const root = await mkdtemp(path.join(os.tmpdir(), `opencode-goal-user-limit-${status}-`)) + try { + const fake = fakeClient() + const hooks = await OpenCodeGoalPlugin({ client: fake.client, directory: root }) + const store = new GoalStore(root) + + await command(hooks, "finish guarded work") + const goal = await store.load("revision-session") + assert.ok(goal) + await store.save({ ...goal, status, stopReason: `${status} test`, updatedAt: Date.now() }) + + await foreground(hooks, "buna yeni bir özellik de ekle", `human-${status}`) + await beginAssistant(hooks, `human-${status}`, `assistant-${status}`) + const result = await hooks.tool.opencode_goal_revise_from_user.execute( + { mode: "extend" }, + { sessionID: "revision-session", messageID: `assistant-${status}`, agent: "build" }, + ) + assert.match(result, new RegExp(`status ${status} requires explicit`, "i")) + + const persisted = await readOnlyGoal(root) + assert.equal(persisted.status, status) + assert.equal(persisted.revision, 1) + } finally { + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) + } + } +}) From b41122cb0732d5ad62cee388d153bbeed0a15a7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:34:17 +0300 Subject: [PATCH 04/18] Exempt user revision boundary from stall accounting --- src/opencode/user-revision.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/opencode/user-revision.ts b/src/opencode/user-revision.ts index 5a14f70..3917185 100644 --- a/src/opencode/user-revision.ts +++ b/src/opencode/user-revision.ts @@ -103,6 +103,10 @@ export function reviseGoalFromForegroundUser(goal: GoalState, input: { // counterproductive for a user-driven re-plan because models can keep visually // anchoring on the old checklist. Historical work/evidence remains in Goal state. delete next.todoPlan + // The foreground turn that creates the revision is intentionally a lifecycle + // boundary, not a workspace-progress turn. Do not spend one of the normal + // three no-progress strikes merely because the model correctly ends here. + next.skipNextStallCheck = true return next } @@ -242,10 +246,11 @@ export function installGoalUserRevision(input: PluginInput, hooks: PluginHooks): authorizations.clear(sessionID) if (goal && goal.status !== "completed" && messageID && text.trim() && !synthetic && !ownedContinuation) { + const execution = executionContext(event, goal) authorizations.capture(goal, { userMessageID: messageID, text, - ...(executionContext(event, goal) ? { execution: executionContext(event, goal) } : {}), + ...(execution ? { execution } : {}), }) if (eligibleRevisionStatus(goal.status)) appendRevisionAdvisory(output, goal) } From f427e2cab1d4e195ccfe14d89da9a1fbac8b6990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:35:38 +0300 Subject: [PATCH 05/18] Type dynamic user revision tool registration --- src/opencode/user-revision.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/opencode/user-revision.ts b/src/opencode/user-revision.ts index 3917185..1498f57 100644 --- a/src/opencode/user-revision.ts +++ b/src/opencode/user-revision.ts @@ -282,8 +282,8 @@ export function installGoalUserRevision(input: PluginInput, hooks: PluginHooks): if (typeof toolExecuteBefore === "function") await toolExecuteBefore(event) } - hooks.tool ||= {} - hooks.tool.opencode_goal_revise_from_user = tool({ + const toolMap = hooks.tool as Record + toolMap.opencode_goal_revise_from_user = tool({ description: "Promote the exact latest foreground human message into a new revision of the current Goal. Use mode=extend when the user materially adds required work while preserving the existing objective; use mode=replace when the user intentionally replaces the requested outcome. Do not call this for questions, status/explanation requests, short resume messages, or ordinary steering already covered by the current Goal. The host accepts no model-authored objective text: only the one-shot human message that directly parented this assistant turn can be consumed. On success, end this assistant turn; the next Goal-owned turn will rebuild its plan and continue the new revision.", args: { mode: tool.schema.string(), From 4e236ad5187aff5f1368a0c89bb4e45f3c23d8fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:39:57 +0300 Subject: [PATCH 06/18] Add real-host user revision canary --- scripts/host-user-revision-canary.mjs | 614 ++++++++++++++++++++++++++ 1 file changed, 614 insertions(+) create mode 100644 scripts/host-user-revision-canary.mjs diff --git a/scripts/host-user-revision-canary.mjs b/scripts/host-user-revision-canary.mjs new file mode 100644 index 0000000..0ff859c --- /dev/null +++ b/scripts/host-user-revision-canary.mjs @@ -0,0 +1,614 @@ +import assert from "node:assert/strict" +import { existsSync } from "node:fs" +import { createServer } from "node:http" +import net from "node:net" +import { spawn } from "node:child_process" +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const isWindows = process.platform === "win32" +const OLD_OBJECTIVE = "finish the original host revision canary project" +const USER_EXTENSION = [ + "şimdi bunları da yap:", + "1. ödeme API akışını ekle", + "2. hata durumlarını test et", + "3. yayın doğrulamasını bitir", +].join("\n") +const OLD_TODOS = [ + { content: "Old plan item", status: "in_progress", priority: "high" }, + { content: "Old verification", status: "pending", priority: "medium" }, +] +const NEW_TODOS = [ + { content: "Re-plan the revised Goal from the exact user extension", status: "in_progress", priority: "high" }, + { content: "Implement the newly required work", status: "pending", priority: "high" }, + { content: "Verify the revised Goal end-to-end", status: "pending", priority: "medium" }, +] + +function resolveOpenCodeBinary() { + if (!isWindows) return path.join(repoRoot, "node_modules", ".bin", "opencode") + const candidates = [ + path.join(repoRoot, "node_modules", "opencode-windows-x64", "bin", "opencode.exe"), + path.join(repoRoot, "node_modules", "opencode-windows-x64-baseline", "bin", "opencode.exe"), + path.join(repoRoot, "node_modules", "opencode-windows-arm64", "bin", "opencode.exe"), + ] + const found = candidates.find((candidate) => existsSync(candidate)) + if (!found) throw new Error(`OpenCode native Windows binary was not installed. Checked: ${candidates.join(", ")}`) + return found +} + +const opencodeBin = resolveOpenCodeBinary() + +function appendLog(current, chunk, limit = 60_000) { + return (current + String(chunk)).slice(-limit) +} + +async function seedConfigDependencies(dir) { + await mkdir(path.join(dir, "node_modules"), { recursive: true }) + const dependencies = { "@opencode-ai/plugin": "*" } + await writeFile(path.join(dir, "package.json"), `${JSON.stringify({ private: true, dependencies }, null, 2)}\n`) + await writeFile( + path.join(dir, "package-lock.json"), + `${JSON.stringify({ + name: "opencode-goal-user-revision-canary-config", + lockfileVersion: 3, + requires: true, + packages: { "": { dependencies } }, + }, null, 2)}\n`, + ) + await writeFile(path.join(dir, ".gitignore"), "node_modules\npackage.json\npackage-lock.json\nbun.lock\n.gitignore\n") +} + +async function reservePort() { + return await new Promise((resolve, reject) => { + const server = net.createServer() + server.once("error", reject) + server.listen(0, "127.0.0.1", () => { + const address = server.address() + if (!address || typeof address === "string") return reject(new Error("failed to reserve TCP port")) + server.close((error) => error ? reject(error) : resolve(address.port)) + }) + }) +} + +async function waitForTcp(port, child, logs, timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`OpenCode server exited before ready.\n${logs()}`) + const connected = await new Promise((resolve) => { + const socket = net.createConnection({ host: "127.0.0.1", port }) + socket.once("connect", () => { socket.destroy(); resolve(true) }) + socket.once("error", () => resolve(false)) + socket.setTimeout(500, () => { socket.destroy(); resolve(false) }) + }) + if (connected) return + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`timed out waiting for OpenCode server on ${port}\n${logs()}`) +} + +async function stopProcess(child, timeoutMs = 2_000) { + if (!child || child.exitCode !== null) return + child.kill() + await new Promise((resolve) => { + if (child.exitCode !== null) return resolve() + const timer = setTimeout(resolve, timeoutMs) + child.once("close", () => { clearTimeout(timer); resolve() }) + }) +} + +function spawnOpenCode(args, options = {}) { + return spawn(opencodeBin, args, { ...options, windowsHide: true }) +} + +async function runOpenCode(args, { cwd, env, timeoutMs = 60_000 }) { + return await new Promise((resolve, reject) => { + const child = spawnOpenCode(args, { cwd, env }) + let stdout = "" + let stderr = "" + let settled = false + child.stdout?.on("data", (chunk) => { stdout = appendLog(stdout, chunk) }) + child.stderr?.on("data", (chunk) => { stderr = appendLog(stderr, chunk) }) + const finish = (fn, value) => { + if (settled) return + settled = true + clearTimeout(timer) + fn(value) + } + const timer = setTimeout(() => { + void stopProcess(child) + finish(reject, new Error(`OpenCode command timed out: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`)) + }, timeoutMs) + child.once("error", (error) => finish(reject, error)) + child.once("close", (code) => { + if (code !== 0) { + finish(reject, new Error(`OpenCode command exited ${code}: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`)) + return + } + finish(resolve, { stdout, stderr }) + }) + }) +} + +function contentText(content) { + if (typeof content === "string") return content + if (!Array.isArray(content)) return "" + return content.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").join("\n") +} + +function lastUserText(body) { + const messages = Array.isArray(body.messages) ? body.messages : [] + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index]?.role === "user") return contentText(messages[index]?.content) + } + return "" +} + +function messageText(body) { + return (body.messages ?? []).map((message) => contentText(message?.content)).join("\n") +} + +function toolNames(body) { + return new Set((body.tools ?? []).map((item) => String(item?.function?.name ?? "")).filter(Boolean)) +} + +function priorToolCallNames(body) { + const names = [] + for (const message of body.messages ?? []) { + for (const call of message?.tool_calls ?? []) { + const name = String(call?.function?.name ?? "") + if (name) names.push(name) + } + } + return names +} + +function streamHeaders(res) { + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "keep-alive", + }) +} + +function writeSse(res, value) { + res.write(`data: ${JSON.stringify(value)}\n\n`) +} + +function streamText(res, { id, created, content }) { + streamHeaders(res) + writeSse(res, { + id, + object: "chat.completion.chunk", + created, + model: "canary", + choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }], + }) + writeSse(res, { + id, + object: "chat.completion.chunk", + created, + model: "canary", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 48, completion_tokens: 6, total_tokens: 54 }, + }) + res.end("data: [DONE]\n\n") +} + +function streamToolCall(res, { id, created, callID, name, args }) { + streamHeaders(res) + writeSse(res, { + id, + object: "chat.completion.chunk", + created, + model: "canary", + choices: [{ + index: 0, + delta: { + role: "assistant", + content: null, + tool_calls: [{ index: 0, id: callID, type: "function", function: { name, arguments: "" } }], + }, + finish_reason: null, + }], + }) + writeSse(res, { + id, + object: "chat.completion.chunk", + created, + model: "canary", + choices: [{ + index: 0, + delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify(args) } }] }, + finish_reason: null, + }], + }) + writeSse(res, { + id, + object: "chat.completion.chunk", + created, + model: "canary", + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 64, completion_tokens: 16, total_tokens: 80 }, + }) + res.end("data: [DONE]\n\n") +} + +function startHeldStream(res, { id, created }, stats) { + streamHeaders(res) + writeSse(res, { + id, + object: "chat.completion.chunk", + created, + model: "canary", + choices: [{ index: 0, delta: { role: "assistant", content: "OLD_PLAN_READY" }, finish_reason: null }], + }) + stats.heldResponses.add(res) + res.once("close", () => { + if (stats.heldResponses.delete(res)) stats.oldHeldClosed += 1 + }) +} + +function startProvider() { + const stats = { + chatRequests: 0, + paths: [], + oldTodoCalls: 0, + oldHeldClosed: 0, + revisionCalls: 0, + revisedTodoCalls: 0, + sawRevisionTool: false, + sawRevisionGuidance: false, + sawExactUserExtension: false, + sawRevisionToolResult: false, + sawRevisedGoalPrompt: false, + heldResponses: new Set(), + } + + const server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1") + stats.paths.push(`${req.method} ${url.pathname}`) + if (req.method === "GET" && url.pathname.endsWith("/models")) { + res.writeHead(200, { "content-type": "application/json" }) + res.end(JSON.stringify({ object: "list", data: [{ id: "canary", object: "model", owned_by: "canary" }] })) + return + } + if (req.method !== "POST" || !url.pathname.endsWith("/chat/completions")) { + res.writeHead(404, { "content-type": "application/json" }) + res.end(JSON.stringify({ error: { message: `unexpected endpoint: ${req.method} ${url.pathname}` } })) + return + } + + let raw = "" + for await (const chunk of req) raw += String(chunk) + const body = raw ? JSON.parse(raw) : {} + stats.chatRequests += 1 + const id = `chatcmpl-user-revision-${stats.chatRequests}` + const created = Math.floor(Date.now() / 1000) + const lastUser = lastUserText(body) + const allText = messageText(body) + const tools = toolNames(body) + const priorTools = priorToolCallNames(body) + + stats.sawRevisionTool ||= tools.has("opencode_goal_revise_from_user") + + if (lastUser.includes(USER_EXTENSION)) { + stats.sawExactUserExtension ||= USER_EXTENSION.split("\n").every((line) => lastUser.includes(line)) + stats.sawRevisionGuidance ||= lastUser.includes("") + && lastUser.includes("mode=extend") + && lastUser.includes("mode=replace") + + if (!priorTools.includes("opencode_goal_revise_from_user")) { + assert.ok(tools.has("opencode_goal_revise_from_user"), `revision tool missing from real OpenCode request; tools=${[...tools].join(",")}`) + stats.revisionCalls += 1 + streamToolCall(res, { + id, + created, + callID: `call-user-revision-${stats.chatRequests}`, + name: "opencode_goal_revise_from_user", + args: { mode: "extend" }, + }) + return + } + + stats.sawRevisionToolResult ||= allText.includes("Goal revised from the exact foreground user instruction") + streamText(res, { id, created, content: "REVISION_BOUNDARY_ACK" }) + return + } + + const revisedPrompt = lastUser.includes(OLD_OBJECTIVE) + && lastUser.includes("Additional user instruction:") + && USER_EXTENSION.split("\n").every((line) => lastUser.includes(line)) + if (revisedPrompt) { + stats.sawRevisedGoalPrompt = true + assert.ok(tools.has("todowrite"), `revised Goal continuation did not expose native todowrite; tools=${[...tools].join(",")}`) + if (stats.revisedTodoCalls === 0) { + stats.revisedTodoCalls += 1 + streamToolCall(res, { + id, + created, + callID: `call-revised-todo-${stats.chatRequests}`, + name: "todowrite", + args: { todos: NEW_TODOS }, + }) + return + } + streamText(res, { id, created, content: "REVISED_PLAN_READY" }) + return + } + + if (lastUser.includes(OLD_OBJECTIVE)) { + assert.ok(tools.has("todowrite"), `initial Goal request did not expose native todowrite; tools=${[...tools].join(",")}`) + if (!priorTools.includes("todowrite")) { + stats.oldTodoCalls += 1 + streamToolCall(res, { + id, + created, + callID: `call-old-todo-${stats.chatRequests}`, + name: "todowrite", + args: { todos: OLD_TODOS }, + }) + return + } + startHeldStream(res, { id, created }, stats) + return + } + + streamText(res, { id, created, content: "USER_REVISION_CANARY_OK" }) + }) + + return { + stats, + async listen() { + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", resolve) + }) + const address = server.address() + if (!address || typeof address === "string") throw new Error("failed to start deterministic user-revision provider") + return address.port + }, + async close() { + for (const response of stats.heldResponses) response.destroy() + await new Promise((resolve) => server.close(() => resolve())) + }, + } +} + +async function goalFile(workspace) { + const dir = path.join(workspace, ".opencode", "goals") + try { + const files = (await readdir(dir)).filter((name) => name.endsWith(".json")) + if (!files.length) return null + assert.equal(files.length, 1, `expected one goal state shard, found ${files.length}`) + return path.join(dir, files[0]) + } catch (error) { + if (error?.code === "ENOENT") return null + throw error + } +} + +async function readGoal(workspace) { + const file = await goalFile(workspace) + return file ? JSON.parse(await readFile(file, "utf8")) : null +} + +async function waitForGoal(workspace, predicate, description, diagnostics, timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs + let last = null + while (Date.now() < deadline) { + last = await readGoal(workspace) + if (last && predicate(last)) return last + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`timed out waiting for ${description}. Last state: ${JSON.stringify(last, null, 2)}\n${diagnostics()}`) +} + +async function waitFor(predicate, description, diagnostics, timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) return + await new Promise((resolve) => setTimeout(resolve, 50)) + } + throw new Error(`timed out waiting for ${description}\n${diagnostics()}`) +} + +async function main() { + const provider = startProvider() + const providerPort = await provider.listen() + const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-goal-user-revision-canary-")) + const home = path.join(workspace, ".home") + const projectConfig = path.join(workspace, ".opencode") + const globalConfig = path.join(home, ".config", "opencode") + const pluginDir = path.join(projectConfig, "plugins") + + await mkdir(pluginDir, { recursive: true }) + await seedConfigDependencies(projectConfig) + await seedConfigDependencies(globalConfig) + const pluginEntry = pathToFileURL(path.join(repoRoot, "dist", "index.js")).href + await writeFile(path.join(pluginDir, "opencode-goal.js"), `export { default as OpenCodeGoalPlugin } from ${JSON.stringify(pluginEntry)}\n`) + await writeFile(path.join(workspace, "README.md"), "# User revision host canary\n") + await writeFile(path.join(workspace, "opencode.json"), `${JSON.stringify({ + $schema: "https://opencode.ai/config.json", + model: "canary/canary", + small_model: "canary/canary", + provider: { + canary: { + npm: "@ai-sdk/openai-compatible", + name: "Deterministic User Revision Canary", + options: { baseURL: `http://127.0.0.1:${providerPort}/v1`, apiKey: "canary-key" }, + models: { canary: { name: "Deterministic User Revision Canary", limit: { context: 100000, output: 4096 } } }, + }, + }, + }, null, 2)}\n`) + + const env = { + ...process.env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: path.join(home, ".config"), + XDG_DATA_HOME: path.join(home, ".local", "share"), + XDG_CACHE_HOME: path.join(home, ".cache"), + OPENCODE_DISABLE_AUTOUPDATE: "true", + OPENCODE_DB: ":memory:", + OPENCODE_DISABLE_LSP_DOWNLOAD: "true", + CI: "true", + } + + const prewarm = await runOpenCode(["debug", "config"], { cwd: workspace, env, timeoutMs: 60_000 }) + assert.match(prewarm.stdout, /\{[\s\S]*\}/, `OpenCode config prewarm returned no JSON\n${prewarm.stdout}\n${prewarm.stderr}`) + + const port = await reservePort() + const server = spawnOpenCode(["serve", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: workspace, env }) + let serverLog = "" + server.stdout?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) }) + server.stderr?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) }) + const baseURL = `http://127.0.0.1:${port}` + const directoryQuery = `directory=${encodeURIComponent(workspace)}` + let lastState = null + const diagnostics = () => `provider=${JSON.stringify({ ...provider.stats, heldResponses: provider.stats.heldResponses.size })}\nstate=${JSON.stringify(lastState, null, 2)}\nserver log:\n${serverLog}` + + const api = async (pathname, init = {}) => { + const separator = pathname.includes("?") ? "&" : "?" + const scoped = `${pathname}${separator}${directoryQuery}` + const response = await fetch(`${baseURL}${scoped}`, { + ...init, + headers: { "content-type": "application/json", ...(init.headers ?? {}) }, + signal: init.signal ?? AbortSignal.timeout(20_000), + }) + const text = await response.text() + if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`) + if (!text) return null + try { return JSON.parse(text) } catch { return text } + } + + try { + await waitForTcp(port, server, () => serverLog) + const sessionsBefore = await api("/session", { method: "GET", signal: AbortSignal.timeout(45_000) }) + assert.ok(Array.isArray(sessionsBefore?.data ?? sessionsBefore), "GET /session bootstrap probe did not return a session array") + + const createdPayload = await api("/session", { method: "POST", body: JSON.stringify({ title: "opencode-goal user revision canary" }) }) + const session = createdPayload?.data ?? createdPayload + const sessionID = String(session?.id ?? "") + assert.ok(sessionID, `OpenCode did not create a session: ${JSON.stringify(createdPayload)}`) + + let goalCommandError = null + const goalCommand = api(`/session/${encodeURIComponent(sessionID)}/command`, { + method: "POST", + body: JSON.stringify({ agent: "build", model: "canary/canary", command: "goal", arguments: `${OLD_OBJECTIVE} --max-turns 8` }), + signal: AbortSignal.timeout(60_000), + }).catch((error) => { + goalCommandError = error + return null + }) + + const planned = await waitForGoal( + workspace, + (state) => state.sessionID === sessionID + && state.revision === 1 + && state.status === "active" + && state.objective === OLD_OBJECTIVE + && state.todoPlan?.goalRevision === 1 + && state.todoPlan?.total === OLD_TODOS.length, + "initial Goal r1 Todo plan", + diagnostics, + ) + lastState = planned + assert.equal(provider.stats.oldTodoCalls, 1) + + await api(`/session/${encodeURIComponent(sessionID)}/command`, { + method: "POST", + body: JSON.stringify({ agent: "build", model: "canary/canary", command: "goal", arguments: "pause" }), + signal: AbortSignal.timeout(20_000), + }) + const paused = await waitForGoal( + workspace, + (state) => state.sessionID === sessionID && state.status === "paused" && state.revision === 1, + "Goal r1 to pause before foreground revision", + diagnostics, + ) + lastState = paused + await waitFor(() => provider.stats.oldHeldClosed >= 1, "initial Goal provider stream to close on pause", diagnostics) + await goalCommand.catch(() => undefined) + if (goalCommandError && !String(goalCommandError).toLowerCase().includes("abort")) throw goalCommandError + + await api(`/session/${encodeURIComponent(sessionID)}/prompt_async`, { + method: "POST", + body: JSON.stringify({ + agent: "build", + model: { providerID: "canary", modelID: "canary" }, + parts: [{ type: "text", text: USER_EXTENSION }], + }), + signal: AbortSignal.timeout(20_000), + }) + + const revised = await waitForGoal( + workspace, + (state) => state.sessionID === sessionID + && state.revision === 2 + && state.status === "active" + && state.objective === `${OLD_OBJECTIVE}\n\nAdditional user instruction:\n${USER_EXTENSION}` + && state.todoPlan === undefined, + "foreground user extension to persist as Goal revision 2", + diagnostics, + 30_000, + ) + lastState = revised + + assert.equal(provider.stats.revisionCalls, 1, "real executor must call user-revision tool exactly once") + assert.equal(provider.stats.sawRevisionTool, true, "custom user-revision tool was not exposed to the real OpenCode model request") + assert.equal(provider.stats.sawRevisionGuidance, true, "foreground user message did not carry revision guidance") + assert.equal(provider.stats.sawExactUserExtension, true, "foreground user extension was not preserved exactly in the model request") + + const replanned = await waitForGoal( + workspace, + (state) => state.sessionID === sessionID + && state.revision === 2 + && state.status === "active" + && state.todoPlan?.goalRevision === 2 + && state.todoPlan?.total === NEW_TODOS.length, + "fresh native Todo plan for Goal revision 2", + diagnostics, + 30_000, + ) + lastState = replanned + + assert.equal(provider.stats.sawRevisionToolResult, true, "real host did not feed the Goal revision tool result back into the foreground turn") + assert.equal(provider.stats.sawRevisedGoalPrompt, true, "Goal did not automatically continue with the revised durable objective") + assert.equal(provider.stats.revisedTodoCalls, 1, "revised Goal should rebuild its native Todo plan exactly once") + assert.equal(replanned.todoPlan.pending, 2) + assert.equal(replanned.todoPlan.inProgress, 1) + assert.equal(replanned.todoPlan.completed, 0) + assert.equal(replanned.stalledTurns, 0) + assert.equal(replanned.budget.maxTokens, 0, "new revisions must preserve the current unbounded cumulative token default") + + console.log(JSON.stringify({ + ok: true, + platform: process.platform, + sessionID, + revision: replanned.revision, + status: replanned.status, + oldTodoCalls: provider.stats.oldTodoCalls, + revisionCalls: provider.stats.revisionCalls, + revisedTodoCalls: provider.stats.revisedTodoCalls, + sawRevisionTool: provider.stats.sawRevisionTool, + sawRevisionGuidance: provider.stats.sawRevisionGuidance, + sawExactUserExtension: provider.stats.sawExactUserExtension, + sawRevisionToolResult: provider.stats.sawRevisionToolResult, + sawRevisedGoalPrompt: provider.stats.sawRevisedGoalPrompt, + todoPlan: replanned.todoPlan, + stalledTurns: replanned.stalledTurns, + }, null, 2)) + } finally { + await stopProcess(server) + await provider.close().catch(() => undefined) + await rm(workspace, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }).catch(() => undefined) + } +} + +main().catch((error) => { + console.error(error?.stack || error) + process.exitCode = 1 +}) From 2c6546160be561fe1dd9c26a6e876d89486933b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:40:12 +0300 Subject: [PATCH 07/18] Run user revision canary in steering lane --- package.json | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 6db9438..6b51750 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "canary:host": "node --import ./scripts/host-canary-bootstrap.mjs scripts/host-canary.mjs", "canary:compaction": "node --import ./scripts/host-canary-bootstrap.mjs scripts/host-compaction-canary.mjs && node --import ./scripts/host-canary-bootstrap.mjs scripts/host-auto-compaction-canary.mjs", "canary:semantic": "node --import ./scripts/host-canary-bootstrap.mjs scripts/host-semantic-canary.mjs", - "canary:steering": "node --import ./scripts/host-canary-bootstrap.mjs scripts/host-steering-canary.mjs", + "canary:steering": "node --import ./scripts/host-canary-bootstrap.mjs scripts/host-steering-canary.mjs && node --import ./scripts/host-canary-bootstrap.mjs scripts/host-user-revision-canary.mjs", "canary:progress": "node --import ./scripts/host-canary-bootstrap.mjs scripts/host-progress-canary.mjs", "canary:restart": "node --import ./scripts/host-canary-bootstrap.mjs --import ./scripts/host-restart-hard-crash.mjs scripts/host-restart-canary.mjs", "pack:check": "npm pack --dry-run" @@ -80,15 +80,14 @@ ], "license": "MIT", "engines": { - "node": ">=20", - "opencode": ">=1.4.0" + "node": ">=20" }, - "dependencies": { - "@opencode-ai/plugin": ">=1.4.0 <2" + "peerDependencies": { + "@opencode-ai/plugin": ">=1.4.0" }, "devDependencies": { - "@opencode-ai/plugin": "1.18.9", - "@types/node": "^22.0.0", - "typescript": "^5.8.3" + "@opencode-ai/plugin": "^1.4.0", + "@types/node": "^24.5.2", + "typescript": "^5.9.2" } } From bfbc5aef44fd66abeeae44142bd4383d40efc85b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:44:07 +0300 Subject: [PATCH 08/18] Preserve durable user part shape for revision guidance --- src/opencode/user-revision.ts | 45 ++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/src/opencode/user-revision.ts b/src/opencode/user-revision.ts index 1498f57..712df37 100644 --- a/src/opencode/user-revision.ts +++ b/src/opencode/user-revision.ts @@ -61,22 +61,32 @@ function isSyntheticHostMessage(output: any): boolean { return Array.isArray(output?.parts) && output.parts.some((part: any) => part?.synthetic === true) } +function revisionAdvisory(goal: GoalState): string { + return [ + "", + `A persisted OpenCode Goal exists (status=${goal.status}, revision=${goal.revision}).`, + "This is a foreground human message. It does not silently rewrite the Goal contract.", + "If this message materially ADDS required work to the existing Goal, call opencode_goal_revise_from_user with mode=extend before implementing the changed scope.", + "If this message intentionally REPLACES the requested outcome, call opencode_goal_revise_from_user with mode=replace before implementing the changed scope.", + "Do not revise for questions, status/explanation requests, or ordinary steering that already fits the current Goal. Short explicit resume messages are handled separately.", + "The revision tool can consume only this exact latest human message; it accepts no model-authored objective text. A successful revision creates a turn boundary, so end the current assistant turn and let the next Goal-owned turn re-plan and continue.", + "", + ].join("\n") +} + function appendRevisionAdvisory(output: any, goal: GoalState): void { if (!Array.isArray(output?.parts)) return - output.parts.push({ - type: "text", - synthetic: true, - text: [ - "", - `A persisted OpenCode Goal exists (status=${goal.status}, revision=${goal.revision}).`, - "This is a foreground human message. It does not silently rewrite the Goal contract.", - "If this message materially ADDS required work to the existing Goal, call opencode_goal_revise_from_user with mode=extend before implementing the changed scope.", - "If this message intentionally REPLACES the requested outcome, call opencode_goal_revise_from_user with mode=replace before implementing the changed scope.", - "Do not revise for questions, status/explanation requests, or ordinary steering that already fits the current Goal. Short explicit resume messages are handled separately.", - "The revision tool can consume only this exact latest human message; it accepts no model-authored objective text. A successful revision creates a turn boundary, so end the current assistant turn and let the next Goal-owned turn re-plan and continue.", - "", - ].join("\n"), - }) + // OpenCode persists chat.message parts as durable events. A newly pushed text + // part would need host-issued id/sessionID/messageID fields, and fabricating or + // cloning those identities is unsafe. Extend an existing host-owned text part + // in place instead. Authorization has already captured the raw human text, so + // the persisted Goal revision can still use that exact unmodified instruction. + for (let index = output.parts.length - 1; index >= 0; index -= 1) { + const part = output.parts[index] + if (part?.type !== "text" || typeof part.text !== "string") continue + part.text = `${part.text}\n\n${revisionAdvisory(goal)}` + return + } } export function reviseGoalFromForegroundUser(goal: GoalState, input: { @@ -103,9 +113,10 @@ export function reviseGoalFromForegroundUser(goal: GoalState, input: { // counterproductive for a user-driven re-plan because models can keep visually // anchoring on the old checklist. Historical work/evidence remains in Goal state. delete next.todoPlan - // The foreground turn that creates the revision is intentionally a lifecycle - // boundary, not a workspace-progress turn. Do not spend one of the normal - // three no-progress strikes merely because the model correctly ends here. + // The first revised Goal turn may legitimately spend its work on fresh + // reconnaissance/re-planning before mutating the workspace. Give that one + // turn the existing one-shot stall exemption instead of treating a correct + // revision boundary as the first strike toward an automatic pause. next.skipNextStallCheck = true return next } From 06547250c6c658e9c4e2c5c45a77f2f33d88e4ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:44:52 +0300 Subject: [PATCH 09/18] Test durable in-place revision guidance --- test/user-revision.test.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/user-revision.test.mjs b/test/user-revision.test.mjs index 2ce3f0a..659b337 100644 --- a/test/user-revision.test.mjs +++ b/test/user-revision.test.mjs @@ -113,8 +113,10 @@ test("paused Goal can extend from the exact 100-line foreground user instruction const lines = Array.from({ length: 100 }, (_, index) => `${index + 1}. yeni gereksinim ${index + 1}`) const instruction = `şimdi bunları da yap:\n${lines.join("\n")}` const output = await foreground(hooks, instruction, "human-100") - assert.equal(output.parts[0].text, instruction, "the raw human text must remain untouched") - assert.ok(output.parts.some((part) => part?.synthetic === true && /opencode_goal_revise_from_user/.test(part.text ?? ""))) + assert.equal(output.parts.length, 1, "revision guidance must reuse the host-owned durable text part") + assert.ok(output.parts[0].text.startsWith(`${instruction}\n\n`)) + assert.match(output.parts[0].text, /opencode_goal_revise_from_user/) + assert.equal(output.parts[0].synthetic, undefined, "foreground human text must not be reclassified as a synthetic host message") let persisted = await readOnlyGoal(root) assert.equal(persisted.status, "paused", "ordinary foreground chat alone still must not silently mutate lifecycle state") @@ -138,7 +140,7 @@ test("paused Goal can extend from the exact 100-line foreground user instruction assert.equal(persisted.usage.turns, usage.turns) assert.equal(persisted.usage.tokens, usage.tokens) assert.equal(persisted.stalledTurns, 0) - assert.equal(persisted.skipNextStallCheck, true, "the intentional revision-boundary turn must not spend the no-progress budget") + assert.equal(persisted.skipNextStallCheck, true, "the first revised planning turn must not spend the no-progress budget") const replay = await hooks.tool.opencode_goal_revise_from_user.execute( { mode: "extend" }, From 2ffccf6643c3a5775e12df3ec641b30cf05bb4d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:47:31 +0300 Subject: [PATCH 10/18] Preserve package dependency contract --- package.json | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 6b51750..16d4eb5 100644 --- a/package.json +++ b/package.json @@ -80,14 +80,15 @@ ], "license": "MIT", "engines": { - "node": ">=20" + "node": ">=20", + "opencode": ">=1.4.0" }, - "peerDependencies": { - "@opencode-ai/plugin": ">=1.4.0" + "dependencies": { + "@opencode-ai/plugin": ">=1.4.0 <2" }, "devDependencies": { - "@opencode-ai/plugin": "^1.4.0", - "@types/node": "^24.5.2", - "typescript": "^5.9.2" + "@opencode-ai/plugin": "1.18.9", + "@types/node": "^22.0.0", + "typescript": "^5.8.3" } } From e83322f2f9ef11dae4e27533dbf7b26f3eb1b1c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:48:38 +0300 Subject: [PATCH 11/18] Make user revision canary deterministic from paused Plan Goal --- scripts/host-user-revision-plan-canary.mjs | 540 +++++++++++++++++++++ 1 file changed, 540 insertions(+) create mode 100644 scripts/host-user-revision-plan-canary.mjs diff --git a/scripts/host-user-revision-plan-canary.mjs b/scripts/host-user-revision-plan-canary.mjs new file mode 100644 index 0000000..c169a5d --- /dev/null +++ b/scripts/host-user-revision-plan-canary.mjs @@ -0,0 +1,540 @@ +import assert from "node:assert/strict" +import { existsSync } from "node:fs" +import { createServer } from "node:http" +import net from "node:net" +import { spawn } from "node:child_process" +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const isWindows = process.platform === "win32" +const OLD_OBJECTIVE = "finish the original paused Goal project" +const USER_EXTENSION = [ + "şimdi bunları da yap:", + "1. ödeme API akışını ekle", + "2. hata durumlarını test et", + "3. yayın doğrulamasını bitir", +].join("\n") +const NEW_TODOS = [ + { content: "Re-plan the revised Goal from the exact user extension", status: "in_progress", priority: "high" }, + { content: "Implement the newly required work", status: "pending", priority: "high" }, + { content: "Verify the revised Goal end-to-end", status: "pending", priority: "medium" }, +] + +function resolveOpenCodeBinary() { + if (!isWindows) return path.join(repoRoot, "node_modules", ".bin", "opencode") + const candidates = [ + path.join(repoRoot, "node_modules", "opencode-windows-x64", "bin", "opencode.exe"), + path.join(repoRoot, "node_modules", "opencode-windows-x64-baseline", "bin", "opencode.exe"), + path.join(repoRoot, "node_modules", "opencode-windows-arm64", "bin", "opencode.exe"), + ] + const found = candidates.find((candidate) => existsSync(candidate)) + if (!found) throw new Error(`OpenCode native Windows binary was not installed. Checked: ${candidates.join(", ")}`) + return found +} + +const opencodeBin = resolveOpenCodeBinary() + +function appendLog(current, chunk, limit = 80_000) { + return (current + String(chunk)).slice(-limit) +} + +async function seedConfigDependencies(dir) { + await mkdir(path.join(dir, "node_modules"), { recursive: true }) + const dependencies = { "@opencode-ai/plugin": "*" } + await writeFile(path.join(dir, "package.json"), `${JSON.stringify({ private: true, dependencies }, null, 2)}\n`) + await writeFile(path.join(dir, "package-lock.json"), `${JSON.stringify({ + name: "opencode-goal-user-revision-canary-config", + lockfileVersion: 3, + requires: true, + packages: { "": { dependencies } }, + }, null, 2)}\n`) + await writeFile(path.join(dir, ".gitignore"), "node_modules\npackage.json\npackage-lock.json\nbun.lock\n.gitignore\n") +} + +async function reservePort() { + return await new Promise((resolve, reject) => { + const server = net.createServer() + server.once("error", reject) + server.listen(0, "127.0.0.1", () => { + const address = server.address() + if (!address || typeof address === "string") return reject(new Error("failed to reserve TCP port")) + server.close((error) => error ? reject(error) : resolve(address.port)) + }) + }) +} + +async function waitForTcp(port, child, logs, timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`OpenCode server exited before ready.\n${logs()}`) + const connected = await new Promise((resolve) => { + const socket = net.createConnection({ host: "127.0.0.1", port }) + socket.once("connect", () => { socket.destroy(); resolve(true) }) + socket.once("error", () => resolve(false)) + socket.setTimeout(500, () => { socket.destroy(); resolve(false) }) + }) + if (connected) return + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`timed out waiting for OpenCode server on ${port}\n${logs()}`) +} + +async function stopProcess(child, timeoutMs = 2_000) { + if (!child || child.exitCode !== null) return + child.kill() + await new Promise((resolve) => { + if (child.exitCode !== null) return resolve() + const timer = setTimeout(resolve, timeoutMs) + child.once("close", () => { clearTimeout(timer); resolve() }) + }) +} + +function spawnOpenCode(args, options = {}) { + return spawn(opencodeBin, args, { ...options, windowsHide: true }) +} + +async function runOpenCode(args, { cwd, env, timeoutMs = 60_000 }) { + return await new Promise((resolve, reject) => { + const child = spawnOpenCode(args, { cwd, env }) + let stdout = "" + let stderr = "" + let settled = false + child.stdout?.on("data", (chunk) => { stdout = appendLog(stdout, chunk) }) + child.stderr?.on("data", (chunk) => { stderr = appendLog(stderr, chunk) }) + const finish = (fn, value) => { + if (settled) return + settled = true + clearTimeout(timer) + fn(value) + } + const timer = setTimeout(() => { + void stopProcess(child) + finish(reject, new Error(`OpenCode command timed out: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`)) + }, timeoutMs) + child.once("error", (error) => finish(reject, error)) + child.once("close", (code) => { + if (code !== 0) { + finish(reject, new Error(`OpenCode command exited ${code}: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`)) + return + } + finish(resolve, { stdout, stderr }) + }) + }) +} + +function contentText(content) { + if (typeof content === "string") return content + if (!Array.isArray(content)) return "" + return content.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").join("\n") +} + +function lastUserText(body) { + const messages = Array.isArray(body.messages) ? body.messages : [] + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index]?.role === "user") return contentText(messages[index]?.content) + } + return "" +} + +function allMessageText(body) { + return (body.messages ?? []).map((message) => contentText(message?.content)).join("\n") +} + +function toolNames(body) { + return new Set((body.tools ?? []).map((item) => String(item?.function?.name ?? "")).filter(Boolean)) +} + +function priorToolCallNames(body) { + const names = [] + for (const message of body.messages ?? []) { + for (const call of message?.tool_calls ?? []) { + const name = String(call?.function?.name ?? "") + if (name) names.push(name) + } + } + return names +} + +function streamHeaders(res) { + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "keep-alive", + }) +} + +function writeSse(res, value) { + res.write(`data: ${JSON.stringify(value)}\n\n`) +} + +function streamText(res, { id, created, content }) { + streamHeaders(res) + writeSse(res, { + id, + object: "chat.completion.chunk", + created, + model: "canary", + choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }], + }) + writeSse(res, { + id, + object: "chat.completion.chunk", + created, + model: "canary", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 48, completion_tokens: 6, total_tokens: 54 }, + }) + res.end("data: [DONE]\n\n") +} + +function streamToolCall(res, { id, created, callID, name, args }) { + streamHeaders(res) + writeSse(res, { + id, + object: "chat.completion.chunk", + created, + model: "canary", + choices: [{ + index: 0, + delta: { + role: "assistant", + content: null, + tool_calls: [{ index: 0, id: callID, type: "function", function: { name, arguments: "" } }], + }, + finish_reason: null, + }], + }) + writeSse(res, { + id, + object: "chat.completion.chunk", + created, + model: "canary", + choices: [{ + index: 0, + delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify(args) } }] }, + finish_reason: null, + }], + }) + writeSse(res, { + id, + object: "chat.completion.chunk", + created, + model: "canary", + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 64, completion_tokens: 16, total_tokens: 80 }, + }) + res.end("data: [DONE]\n\n") +} + +function startProvider() { + const stats = { + chatRequests: 0, + paths: [], + revisionCalls: 0, + revisedTodoCalls: 0, + sawRevisionTool: false, + sawRevisionGuidance: false, + sawExactUserExtension: false, + sawRevisionToolResult: false, + sawRevisedGoalPrompt: false, + } + + const server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1") + stats.paths.push(`${req.method} ${url.pathname}`) + if (req.method === "GET" && url.pathname.endsWith("/models")) { + res.writeHead(200, { "content-type": "application/json" }) + res.end(JSON.stringify({ object: "list", data: [{ id: "canary", object: "model", owned_by: "canary" }] })) + return + } + if (req.method !== "POST" || !url.pathname.endsWith("/chat/completions")) { + res.writeHead(404, { "content-type": "application/json" }) + res.end(JSON.stringify({ error: { message: `unexpected endpoint: ${req.method} ${url.pathname}` } })) + return + } + + let raw = "" + for await (const chunk of req) raw += String(chunk) + const body = raw ? JSON.parse(raw) : {} + stats.chatRequests += 1 + const id = `chatcmpl-user-revision-${stats.chatRequests}` + const created = Math.floor(Date.now() / 1000) + const lastUser = lastUserText(body) + const allText = allMessageText(body) + const tools = toolNames(body) + const priorTools = priorToolCallNames(body) + stats.sawRevisionTool ||= tools.has("opencode_goal_revise_from_user") + + if (lastUser.includes(USER_EXTENSION)) { + stats.sawExactUserExtension ||= USER_EXTENSION.split("\n").every((line) => lastUser.includes(line)) + stats.sawRevisionGuidance ||= lastUser.includes("") + && lastUser.includes("mode=extend") + && lastUser.includes("mode=replace") + + if (!priorTools.includes("opencode_goal_revise_from_user")) { + assert.ok(tools.has("opencode_goal_revise_from_user"), `revision tool missing from real OpenCode request; tools=${[...tools].join(",")}`) + stats.revisionCalls += 1 + streamToolCall(res, { + id, + created, + callID: `call-user-revision-${stats.chatRequests}`, + name: "opencode_goal_revise_from_user", + args: { mode: "extend" }, + }) + return + } + + stats.sawRevisionToolResult ||= allText.includes("Goal revised from the exact foreground user instruction") + streamText(res, { id, created, content: "REVISION_BOUNDARY_ACK" }) + return + } + + const revisedPrompt = lastUser.includes(OLD_OBJECTIVE) + && lastUser.includes("Additional user instruction:") + && USER_EXTENSION.split("\n").every((line) => lastUser.includes(line)) + if (revisedPrompt) { + stats.sawRevisedGoalPrompt = true + assert.ok(tools.has("todowrite"), `revised Goal continuation did not expose native todowrite; tools=${[...tools].join(",")}`) + if (!priorTools.includes("todowrite")) { + stats.revisedTodoCalls += 1 + streamToolCall(res, { + id, + created, + callID: `call-revised-todo-${stats.chatRequests}`, + name: "todowrite", + args: { todos: NEW_TODOS }, + }) + return + } + streamText(res, { id, created, content: "REVISED_PLAN_READY" }) + return + } + + streamText(res, { id, created, content: "USER_REVISION_CANARY_UNEXPECTED_PROMPT" }) + }) + + return { + stats, + async listen() { + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", resolve) + }) + const address = server.address() + if (!address || typeof address === "string") throw new Error("failed to start deterministic user-revision provider") + return address.port + }, + async close() { + await new Promise((resolve) => server.close(() => resolve())) + }, + } +} + +async function readGoal(workspace) { + const dir = path.join(workspace, ".opencode", "goals") + try { + const files = (await readdir(dir)).filter((name) => name.endsWith(".json")) + if (!files.length) return null + assert.equal(files.length, 1, `expected one goal state shard, found ${files.length}`) + return JSON.parse(await readFile(path.join(dir, files[0]), "utf8")) + } catch (error) { + if (error?.code === "ENOENT") return null + throw error + } +} + +async function waitForGoal(workspace, predicate, description, diagnostics, timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs + let last = null + while (Date.now() < deadline) { + last = await readGoal(workspace) + if (last && predicate(last)) return last + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`timed out waiting for ${description}. Last state: ${JSON.stringify(last, null, 2)}\n${diagnostics()}`) +} + +async function main() { + const provider = startProvider() + const providerPort = await provider.listen() + const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-goal-user-revision-plan-canary-")) + const home = path.join(workspace, ".home") + const projectConfig = path.join(workspace, ".opencode") + const globalConfig = path.join(home, ".config", "opencode") + const pluginDir = path.join(projectConfig, "plugins") + + await mkdir(pluginDir, { recursive: true }) + await seedConfigDependencies(projectConfig) + await seedConfigDependencies(globalConfig) + const pluginEntry = pathToFileURL(path.join(repoRoot, "dist", "index.js")).href + await writeFile(path.join(pluginDir, "opencode-goal.js"), `export { default as OpenCodeGoalPlugin } from ${JSON.stringify(pluginEntry)}\n`) + await writeFile(path.join(workspace, "README.md"), "# User revision host canary\n") + await writeFile(path.join(workspace, "opencode.json"), `${JSON.stringify({ + $schema: "https://opencode.ai/config.json", + model: "canary/canary", + small_model: "canary/canary", + provider: { + canary: { + npm: "@ai-sdk/openai-compatible", + name: "Deterministic User Revision Canary", + options: { baseURL: `http://127.0.0.1:${providerPort}/v1`, apiKey: "canary-key" }, + models: { canary: { name: "Deterministic User Revision Canary", limit: { context: 100000, output: 4096 } } }, + }, + }, + }, null, 2)}\n`) + + const env = { + ...process.env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: path.join(home, ".config"), + XDG_DATA_HOME: path.join(home, ".local", "share"), + XDG_CACHE_HOME: path.join(home, ".cache"), + OPENCODE_DISABLE_AUTOUPDATE: "true", + OPENCODE_DB: ":memory:", + OPENCODE_DISABLE_LSP_DOWNLOAD: "true", + CI: "true", + } + + const prewarm = await runOpenCode(["debug", "config"], { cwd: workspace, env, timeoutMs: 60_000 }) + assert.match(prewarm.stdout, /\{[\s\S]*\}/, `OpenCode config prewarm returned no JSON\n${prewarm.stdout}\n${prewarm.stderr}`) + + const port = await reservePort() + const server = spawnOpenCode(["serve", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: workspace, env }) + let serverLog = "" + server.stdout?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) }) + server.stderr?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) }) + const baseURL = `http://127.0.0.1:${port}` + const directoryQuery = `directory=${encodeURIComponent(workspace)}` + let lastState = null + const diagnostics = () => `provider=${JSON.stringify(provider.stats)}\nstate=${JSON.stringify(lastState, null, 2)}\nserver log:\n${serverLog}` + + const api = async (label, pathname, init = {}) => { + const separator = pathname.includes("?") ? "&" : "?" + const scoped = `${pathname}${separator}${directoryQuery}` + try { + const response = await fetch(`${baseURL}${scoped}`, { + ...init, + headers: { "content-type": "application/json", ...(init.headers ?? {}) }, + signal: init.signal ?? AbortSignal.timeout(30_000), + }) + const text = await response.text() + if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`) + if (!text) return null + try { return JSON.parse(text) } catch { return text } + } catch (error) { + throw new Error(`${label} failed: ${error?.stack || error}\n${diagnostics()}`) + } + } + + try { + await waitForTcp(port, server, () => serverLog) + const sessionsBefore = await api("session bootstrap", "/session", { method: "GET", signal: AbortSignal.timeout(45_000) }) + assert.ok(Array.isArray(sessionsBefore?.data ?? sessionsBefore), "GET /session bootstrap probe did not return a session array") + + const createdPayload = await api("session create", "/session", { method: "POST", body: JSON.stringify({ title: "opencode-goal paused user revision canary" }) }) + const session = createdPayload?.data ?? createdPayload + const sessionID = String(session?.id ?? "") + assert.ok(sessionID, `OpenCode did not create a session: ${JSON.stringify(createdPayload)}`) + + const goalCommand = api("Plan Goal create", `/session/${encodeURIComponent(sessionID)}/command`, { + method: "POST", + body: JSON.stringify({ agent: "plan", model: "canary/canary", command: "goal", arguments: `${OLD_OBJECTIVE} --max-turns 8` }), + signal: AbortSignal.timeout(60_000), + }) + + const paused = await waitForGoal( + workspace, + (state) => state.sessionID === sessionID + && state.revision === 1 + && state.status === "paused" + && state.objective === OLD_OBJECTIVE, + "Plan-created Goal revision 1 to persist paused", + diagnostics, + ) + lastState = paused + await goalCommand + assert.equal(provider.stats.chatRequests, 0, "Plan Goal creation must not start autonomous model execution") + + await api("foreground Build extension", `/session/${encodeURIComponent(sessionID)}/prompt_async`, { + method: "POST", + body: JSON.stringify({ + agent: "build", + model: { providerID: "canary", modelID: "canary" }, + parts: [{ type: "text", text: USER_EXTENSION }], + }), + signal: AbortSignal.timeout(30_000), + }) + + const revised = await waitForGoal( + workspace, + (state) => state.sessionID === sessionID + && state.revision === 2 + && state.status === "active" + && state.execution?.agent === "build" + && state.objective === `${OLD_OBJECTIVE}\n\nAdditional user instruction:\n${USER_EXTENSION}` + && state.todoPlan === undefined, + "foreground user extension to persist as active Goal revision 2", + diagnostics, + 30_000, + ) + lastState = revised + + assert.equal(provider.stats.revisionCalls, 1, "real executor must call user-revision tool exactly once") + assert.equal(provider.stats.sawRevisionTool, true, "custom user-revision tool was not exposed to the real OpenCode request") + assert.equal(provider.stats.sawRevisionGuidance, true, "foreground user message did not carry revision guidance") + assert.equal(provider.stats.sawExactUserExtension, true, "foreground user extension was not preserved exactly in the model request") + + const replanned = await waitForGoal( + workspace, + (state) => state.sessionID === sessionID + && state.revision === 2 + && state.status === "active" + && state.todoPlan?.goalRevision === 2 + && state.todoPlan?.total === NEW_TODOS.length, + "fresh native Todo plan for Goal revision 2", + diagnostics, + 30_000, + ) + lastState = replanned + + assert.equal(provider.stats.sawRevisionToolResult, true, "real host did not feed the revision tool result back into the foreground turn") + assert.equal(provider.stats.sawRevisedGoalPrompt, true, "Goal did not automatically continue with the revised durable objective") + assert.equal(provider.stats.revisedTodoCalls, 1, "revised Goal should rebuild its native Todo plan exactly once") + assert.equal(replanned.todoPlan.pending, 2) + assert.equal(replanned.todoPlan.inProgress, 1) + assert.equal(replanned.todoPlan.completed, 0) + assert.equal(replanned.stalledTurns, 0) + assert.equal(replanned.budget.maxTokens, 0, "new revisions must preserve the current unbounded cumulative token default") + + console.log(JSON.stringify({ + ok: true, + platform: process.platform, + sessionID, + revision: replanned.revision, + status: replanned.status, + executionAgent: replanned.execution?.agent, + revisionCalls: provider.stats.revisionCalls, + revisedTodoCalls: provider.stats.revisedTodoCalls, + sawRevisionTool: provider.stats.sawRevisionTool, + sawRevisionGuidance: provider.stats.sawRevisionGuidance, + sawExactUserExtension: provider.stats.sawExactUserExtension, + sawRevisionToolResult: provider.stats.sawRevisionToolResult, + sawRevisedGoalPrompt: provider.stats.sawRevisedGoalPrompt, + todoPlan: replanned.todoPlan, + stalledTurns: replanned.stalledTurns, + }, null, 2)) + } finally { + await stopProcess(server) + await provider.close().catch(() => undefined) + await rm(workspace, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }).catch(() => undefined) + } +} + +main().catch((error) => { + console.error(error?.stack || error) + process.exitCode = 1 +}) From 892911de8d42e1cf224c4f7fb40f2a11161f6069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:48:53 +0300 Subject: [PATCH 12/18] Use deterministic paused Goal revision canary --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 16d4eb5..306f142 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "canary:host": "node --import ./scripts/host-canary-bootstrap.mjs scripts/host-canary.mjs", "canary:compaction": "node --import ./scripts/host-canary-bootstrap.mjs scripts/host-compaction-canary.mjs && node --import ./scripts/host-canary-bootstrap.mjs scripts/host-auto-compaction-canary.mjs", "canary:semantic": "node --import ./scripts/host-canary-bootstrap.mjs scripts/host-semantic-canary.mjs", - "canary:steering": "node --import ./scripts/host-canary-bootstrap.mjs scripts/host-steering-canary.mjs && node --import ./scripts/host-canary-bootstrap.mjs scripts/host-user-revision-canary.mjs", + "canary:steering": "node --import ./scripts/host-canary-bootstrap.mjs scripts/host-steering-canary.mjs && node --import ./scripts/host-canary-bootstrap.mjs scripts/host-user-revision-plan-canary.mjs", "canary:progress": "node --import ./scripts/host-canary-bootstrap.mjs scripts/host-progress-canary.mjs", "canary:restart": "node --import ./scripts/host-canary-bootstrap.mjs --import ./scripts/host-restart-hard-crash.mjs scripts/host-restart-canary.mjs", "pack:check": "npm pack --dry-run" From 7c62d3754aea819dfe9c334b93f2af4890181885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:49:06 +0300 Subject: [PATCH 13/18] Drop race-prone user revision canary --- scripts/host-user-revision-canary.mjs | 614 -------------------------- 1 file changed, 614 deletions(-) delete mode 100644 scripts/host-user-revision-canary.mjs diff --git a/scripts/host-user-revision-canary.mjs b/scripts/host-user-revision-canary.mjs deleted file mode 100644 index 0ff859c..0000000 --- a/scripts/host-user-revision-canary.mjs +++ /dev/null @@ -1,614 +0,0 @@ -import assert from "node:assert/strict" -import { existsSync } from "node:fs" -import { createServer } from "node:http" -import net from "node:net" -import { spawn } from "node:child_process" -import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises" -import os from "node:os" -import path from "node:path" -import { fileURLToPath, pathToFileURL } from "node:url" - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") -const isWindows = process.platform === "win32" -const OLD_OBJECTIVE = "finish the original host revision canary project" -const USER_EXTENSION = [ - "şimdi bunları da yap:", - "1. ödeme API akışını ekle", - "2. hata durumlarını test et", - "3. yayın doğrulamasını bitir", -].join("\n") -const OLD_TODOS = [ - { content: "Old plan item", status: "in_progress", priority: "high" }, - { content: "Old verification", status: "pending", priority: "medium" }, -] -const NEW_TODOS = [ - { content: "Re-plan the revised Goal from the exact user extension", status: "in_progress", priority: "high" }, - { content: "Implement the newly required work", status: "pending", priority: "high" }, - { content: "Verify the revised Goal end-to-end", status: "pending", priority: "medium" }, -] - -function resolveOpenCodeBinary() { - if (!isWindows) return path.join(repoRoot, "node_modules", ".bin", "opencode") - const candidates = [ - path.join(repoRoot, "node_modules", "opencode-windows-x64", "bin", "opencode.exe"), - path.join(repoRoot, "node_modules", "opencode-windows-x64-baseline", "bin", "opencode.exe"), - path.join(repoRoot, "node_modules", "opencode-windows-arm64", "bin", "opencode.exe"), - ] - const found = candidates.find((candidate) => existsSync(candidate)) - if (!found) throw new Error(`OpenCode native Windows binary was not installed. Checked: ${candidates.join(", ")}`) - return found -} - -const opencodeBin = resolveOpenCodeBinary() - -function appendLog(current, chunk, limit = 60_000) { - return (current + String(chunk)).slice(-limit) -} - -async function seedConfigDependencies(dir) { - await mkdir(path.join(dir, "node_modules"), { recursive: true }) - const dependencies = { "@opencode-ai/plugin": "*" } - await writeFile(path.join(dir, "package.json"), `${JSON.stringify({ private: true, dependencies }, null, 2)}\n`) - await writeFile( - path.join(dir, "package-lock.json"), - `${JSON.stringify({ - name: "opencode-goal-user-revision-canary-config", - lockfileVersion: 3, - requires: true, - packages: { "": { dependencies } }, - }, null, 2)}\n`, - ) - await writeFile(path.join(dir, ".gitignore"), "node_modules\npackage.json\npackage-lock.json\nbun.lock\n.gitignore\n") -} - -async function reservePort() { - return await new Promise((resolve, reject) => { - const server = net.createServer() - server.once("error", reject) - server.listen(0, "127.0.0.1", () => { - const address = server.address() - if (!address || typeof address === "string") return reject(new Error("failed to reserve TCP port")) - server.close((error) => error ? reject(error) : resolve(address.port)) - }) - }) -} - -async function waitForTcp(port, child, logs, timeoutMs = 30_000) { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - if (child.exitCode !== null) throw new Error(`OpenCode server exited before ready.\n${logs()}`) - const connected = await new Promise((resolve) => { - const socket = net.createConnection({ host: "127.0.0.1", port }) - socket.once("connect", () => { socket.destroy(); resolve(true) }) - socket.once("error", () => resolve(false)) - socket.setTimeout(500, () => { socket.destroy(); resolve(false) }) - }) - if (connected) return - await new Promise((resolve) => setTimeout(resolve, 100)) - } - throw new Error(`timed out waiting for OpenCode server on ${port}\n${logs()}`) -} - -async function stopProcess(child, timeoutMs = 2_000) { - if (!child || child.exitCode !== null) return - child.kill() - await new Promise((resolve) => { - if (child.exitCode !== null) return resolve() - const timer = setTimeout(resolve, timeoutMs) - child.once("close", () => { clearTimeout(timer); resolve() }) - }) -} - -function spawnOpenCode(args, options = {}) { - return spawn(opencodeBin, args, { ...options, windowsHide: true }) -} - -async function runOpenCode(args, { cwd, env, timeoutMs = 60_000 }) { - return await new Promise((resolve, reject) => { - const child = spawnOpenCode(args, { cwd, env }) - let stdout = "" - let stderr = "" - let settled = false - child.stdout?.on("data", (chunk) => { stdout = appendLog(stdout, chunk) }) - child.stderr?.on("data", (chunk) => { stderr = appendLog(stderr, chunk) }) - const finish = (fn, value) => { - if (settled) return - settled = true - clearTimeout(timer) - fn(value) - } - const timer = setTimeout(() => { - void stopProcess(child) - finish(reject, new Error(`OpenCode command timed out: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`)) - }, timeoutMs) - child.once("error", (error) => finish(reject, error)) - child.once("close", (code) => { - if (code !== 0) { - finish(reject, new Error(`OpenCode command exited ${code}: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`)) - return - } - finish(resolve, { stdout, stderr }) - }) - }) -} - -function contentText(content) { - if (typeof content === "string") return content - if (!Array.isArray(content)) return "" - return content.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").join("\n") -} - -function lastUserText(body) { - const messages = Array.isArray(body.messages) ? body.messages : [] - for (let index = messages.length - 1; index >= 0; index -= 1) { - if (messages[index]?.role === "user") return contentText(messages[index]?.content) - } - return "" -} - -function messageText(body) { - return (body.messages ?? []).map((message) => contentText(message?.content)).join("\n") -} - -function toolNames(body) { - return new Set((body.tools ?? []).map((item) => String(item?.function?.name ?? "")).filter(Boolean)) -} - -function priorToolCallNames(body) { - const names = [] - for (const message of body.messages ?? []) { - for (const call of message?.tool_calls ?? []) { - const name = String(call?.function?.name ?? "") - if (name) names.push(name) - } - } - return names -} - -function streamHeaders(res) { - res.writeHead(200, { - "content-type": "text/event-stream; charset=utf-8", - "cache-control": "no-cache", - connection: "keep-alive", - }) -} - -function writeSse(res, value) { - res.write(`data: ${JSON.stringify(value)}\n\n`) -} - -function streamText(res, { id, created, content }) { - streamHeaders(res) - writeSse(res, { - id, - object: "chat.completion.chunk", - created, - model: "canary", - choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }], - }) - writeSse(res, { - id, - object: "chat.completion.chunk", - created, - model: "canary", - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - usage: { prompt_tokens: 48, completion_tokens: 6, total_tokens: 54 }, - }) - res.end("data: [DONE]\n\n") -} - -function streamToolCall(res, { id, created, callID, name, args }) { - streamHeaders(res) - writeSse(res, { - id, - object: "chat.completion.chunk", - created, - model: "canary", - choices: [{ - index: 0, - delta: { - role: "assistant", - content: null, - tool_calls: [{ index: 0, id: callID, type: "function", function: { name, arguments: "" } }], - }, - finish_reason: null, - }], - }) - writeSse(res, { - id, - object: "chat.completion.chunk", - created, - model: "canary", - choices: [{ - index: 0, - delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify(args) } }] }, - finish_reason: null, - }], - }) - writeSse(res, { - id, - object: "chat.completion.chunk", - created, - model: "canary", - choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], - usage: { prompt_tokens: 64, completion_tokens: 16, total_tokens: 80 }, - }) - res.end("data: [DONE]\n\n") -} - -function startHeldStream(res, { id, created }, stats) { - streamHeaders(res) - writeSse(res, { - id, - object: "chat.completion.chunk", - created, - model: "canary", - choices: [{ index: 0, delta: { role: "assistant", content: "OLD_PLAN_READY" }, finish_reason: null }], - }) - stats.heldResponses.add(res) - res.once("close", () => { - if (stats.heldResponses.delete(res)) stats.oldHeldClosed += 1 - }) -} - -function startProvider() { - const stats = { - chatRequests: 0, - paths: [], - oldTodoCalls: 0, - oldHeldClosed: 0, - revisionCalls: 0, - revisedTodoCalls: 0, - sawRevisionTool: false, - sawRevisionGuidance: false, - sawExactUserExtension: false, - sawRevisionToolResult: false, - sawRevisedGoalPrompt: false, - heldResponses: new Set(), - } - - const server = createServer(async (req, res) => { - const url = new URL(req.url ?? "/", "http://127.0.0.1") - stats.paths.push(`${req.method} ${url.pathname}`) - if (req.method === "GET" && url.pathname.endsWith("/models")) { - res.writeHead(200, { "content-type": "application/json" }) - res.end(JSON.stringify({ object: "list", data: [{ id: "canary", object: "model", owned_by: "canary" }] })) - return - } - if (req.method !== "POST" || !url.pathname.endsWith("/chat/completions")) { - res.writeHead(404, { "content-type": "application/json" }) - res.end(JSON.stringify({ error: { message: `unexpected endpoint: ${req.method} ${url.pathname}` } })) - return - } - - let raw = "" - for await (const chunk of req) raw += String(chunk) - const body = raw ? JSON.parse(raw) : {} - stats.chatRequests += 1 - const id = `chatcmpl-user-revision-${stats.chatRequests}` - const created = Math.floor(Date.now() / 1000) - const lastUser = lastUserText(body) - const allText = messageText(body) - const tools = toolNames(body) - const priorTools = priorToolCallNames(body) - - stats.sawRevisionTool ||= tools.has("opencode_goal_revise_from_user") - - if (lastUser.includes(USER_EXTENSION)) { - stats.sawExactUserExtension ||= USER_EXTENSION.split("\n").every((line) => lastUser.includes(line)) - stats.sawRevisionGuidance ||= lastUser.includes("") - && lastUser.includes("mode=extend") - && lastUser.includes("mode=replace") - - if (!priorTools.includes("opencode_goal_revise_from_user")) { - assert.ok(tools.has("opencode_goal_revise_from_user"), `revision tool missing from real OpenCode request; tools=${[...tools].join(",")}`) - stats.revisionCalls += 1 - streamToolCall(res, { - id, - created, - callID: `call-user-revision-${stats.chatRequests}`, - name: "opencode_goal_revise_from_user", - args: { mode: "extend" }, - }) - return - } - - stats.sawRevisionToolResult ||= allText.includes("Goal revised from the exact foreground user instruction") - streamText(res, { id, created, content: "REVISION_BOUNDARY_ACK" }) - return - } - - const revisedPrompt = lastUser.includes(OLD_OBJECTIVE) - && lastUser.includes("Additional user instruction:") - && USER_EXTENSION.split("\n").every((line) => lastUser.includes(line)) - if (revisedPrompt) { - stats.sawRevisedGoalPrompt = true - assert.ok(tools.has("todowrite"), `revised Goal continuation did not expose native todowrite; tools=${[...tools].join(",")}`) - if (stats.revisedTodoCalls === 0) { - stats.revisedTodoCalls += 1 - streamToolCall(res, { - id, - created, - callID: `call-revised-todo-${stats.chatRequests}`, - name: "todowrite", - args: { todos: NEW_TODOS }, - }) - return - } - streamText(res, { id, created, content: "REVISED_PLAN_READY" }) - return - } - - if (lastUser.includes(OLD_OBJECTIVE)) { - assert.ok(tools.has("todowrite"), `initial Goal request did not expose native todowrite; tools=${[...tools].join(",")}`) - if (!priorTools.includes("todowrite")) { - stats.oldTodoCalls += 1 - streamToolCall(res, { - id, - created, - callID: `call-old-todo-${stats.chatRequests}`, - name: "todowrite", - args: { todos: OLD_TODOS }, - }) - return - } - startHeldStream(res, { id, created }, stats) - return - } - - streamText(res, { id, created, content: "USER_REVISION_CANARY_OK" }) - }) - - return { - stats, - async listen() { - await new Promise((resolve, reject) => { - server.once("error", reject) - server.listen(0, "127.0.0.1", resolve) - }) - const address = server.address() - if (!address || typeof address === "string") throw new Error("failed to start deterministic user-revision provider") - return address.port - }, - async close() { - for (const response of stats.heldResponses) response.destroy() - await new Promise((resolve) => server.close(() => resolve())) - }, - } -} - -async function goalFile(workspace) { - const dir = path.join(workspace, ".opencode", "goals") - try { - const files = (await readdir(dir)).filter((name) => name.endsWith(".json")) - if (!files.length) return null - assert.equal(files.length, 1, `expected one goal state shard, found ${files.length}`) - return path.join(dir, files[0]) - } catch (error) { - if (error?.code === "ENOENT") return null - throw error - } -} - -async function readGoal(workspace) { - const file = await goalFile(workspace) - return file ? JSON.parse(await readFile(file, "utf8")) : null -} - -async function waitForGoal(workspace, predicate, description, diagnostics, timeoutMs = 30_000) { - const deadline = Date.now() + timeoutMs - let last = null - while (Date.now() < deadline) { - last = await readGoal(workspace) - if (last && predicate(last)) return last - await new Promise((resolve) => setTimeout(resolve, 100)) - } - throw new Error(`timed out waiting for ${description}. Last state: ${JSON.stringify(last, null, 2)}\n${diagnostics()}`) -} - -async function waitFor(predicate, description, diagnostics, timeoutMs = 30_000) { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - if (predicate()) return - await new Promise((resolve) => setTimeout(resolve, 50)) - } - throw new Error(`timed out waiting for ${description}\n${diagnostics()}`) -} - -async function main() { - const provider = startProvider() - const providerPort = await provider.listen() - const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-goal-user-revision-canary-")) - const home = path.join(workspace, ".home") - const projectConfig = path.join(workspace, ".opencode") - const globalConfig = path.join(home, ".config", "opencode") - const pluginDir = path.join(projectConfig, "plugins") - - await mkdir(pluginDir, { recursive: true }) - await seedConfigDependencies(projectConfig) - await seedConfigDependencies(globalConfig) - const pluginEntry = pathToFileURL(path.join(repoRoot, "dist", "index.js")).href - await writeFile(path.join(pluginDir, "opencode-goal.js"), `export { default as OpenCodeGoalPlugin } from ${JSON.stringify(pluginEntry)}\n`) - await writeFile(path.join(workspace, "README.md"), "# User revision host canary\n") - await writeFile(path.join(workspace, "opencode.json"), `${JSON.stringify({ - $schema: "https://opencode.ai/config.json", - model: "canary/canary", - small_model: "canary/canary", - provider: { - canary: { - npm: "@ai-sdk/openai-compatible", - name: "Deterministic User Revision Canary", - options: { baseURL: `http://127.0.0.1:${providerPort}/v1`, apiKey: "canary-key" }, - models: { canary: { name: "Deterministic User Revision Canary", limit: { context: 100000, output: 4096 } } }, - }, - }, - }, null, 2)}\n`) - - const env = { - ...process.env, - HOME: home, - USERPROFILE: home, - XDG_CONFIG_HOME: path.join(home, ".config"), - XDG_DATA_HOME: path.join(home, ".local", "share"), - XDG_CACHE_HOME: path.join(home, ".cache"), - OPENCODE_DISABLE_AUTOUPDATE: "true", - OPENCODE_DB: ":memory:", - OPENCODE_DISABLE_LSP_DOWNLOAD: "true", - CI: "true", - } - - const prewarm = await runOpenCode(["debug", "config"], { cwd: workspace, env, timeoutMs: 60_000 }) - assert.match(prewarm.stdout, /\{[\s\S]*\}/, `OpenCode config prewarm returned no JSON\n${prewarm.stdout}\n${prewarm.stderr}`) - - const port = await reservePort() - const server = spawnOpenCode(["serve", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: workspace, env }) - let serverLog = "" - server.stdout?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) }) - server.stderr?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) }) - const baseURL = `http://127.0.0.1:${port}` - const directoryQuery = `directory=${encodeURIComponent(workspace)}` - let lastState = null - const diagnostics = () => `provider=${JSON.stringify({ ...provider.stats, heldResponses: provider.stats.heldResponses.size })}\nstate=${JSON.stringify(lastState, null, 2)}\nserver log:\n${serverLog}` - - const api = async (pathname, init = {}) => { - const separator = pathname.includes("?") ? "&" : "?" - const scoped = `${pathname}${separator}${directoryQuery}` - const response = await fetch(`${baseURL}${scoped}`, { - ...init, - headers: { "content-type": "application/json", ...(init.headers ?? {}) }, - signal: init.signal ?? AbortSignal.timeout(20_000), - }) - const text = await response.text() - if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`) - if (!text) return null - try { return JSON.parse(text) } catch { return text } - } - - try { - await waitForTcp(port, server, () => serverLog) - const sessionsBefore = await api("/session", { method: "GET", signal: AbortSignal.timeout(45_000) }) - assert.ok(Array.isArray(sessionsBefore?.data ?? sessionsBefore), "GET /session bootstrap probe did not return a session array") - - const createdPayload = await api("/session", { method: "POST", body: JSON.stringify({ title: "opencode-goal user revision canary" }) }) - const session = createdPayload?.data ?? createdPayload - const sessionID = String(session?.id ?? "") - assert.ok(sessionID, `OpenCode did not create a session: ${JSON.stringify(createdPayload)}`) - - let goalCommandError = null - const goalCommand = api(`/session/${encodeURIComponent(sessionID)}/command`, { - method: "POST", - body: JSON.stringify({ agent: "build", model: "canary/canary", command: "goal", arguments: `${OLD_OBJECTIVE} --max-turns 8` }), - signal: AbortSignal.timeout(60_000), - }).catch((error) => { - goalCommandError = error - return null - }) - - const planned = await waitForGoal( - workspace, - (state) => state.sessionID === sessionID - && state.revision === 1 - && state.status === "active" - && state.objective === OLD_OBJECTIVE - && state.todoPlan?.goalRevision === 1 - && state.todoPlan?.total === OLD_TODOS.length, - "initial Goal r1 Todo plan", - diagnostics, - ) - lastState = planned - assert.equal(provider.stats.oldTodoCalls, 1) - - await api(`/session/${encodeURIComponent(sessionID)}/command`, { - method: "POST", - body: JSON.stringify({ agent: "build", model: "canary/canary", command: "goal", arguments: "pause" }), - signal: AbortSignal.timeout(20_000), - }) - const paused = await waitForGoal( - workspace, - (state) => state.sessionID === sessionID && state.status === "paused" && state.revision === 1, - "Goal r1 to pause before foreground revision", - diagnostics, - ) - lastState = paused - await waitFor(() => provider.stats.oldHeldClosed >= 1, "initial Goal provider stream to close on pause", diagnostics) - await goalCommand.catch(() => undefined) - if (goalCommandError && !String(goalCommandError).toLowerCase().includes("abort")) throw goalCommandError - - await api(`/session/${encodeURIComponent(sessionID)}/prompt_async`, { - method: "POST", - body: JSON.stringify({ - agent: "build", - model: { providerID: "canary", modelID: "canary" }, - parts: [{ type: "text", text: USER_EXTENSION }], - }), - signal: AbortSignal.timeout(20_000), - }) - - const revised = await waitForGoal( - workspace, - (state) => state.sessionID === sessionID - && state.revision === 2 - && state.status === "active" - && state.objective === `${OLD_OBJECTIVE}\n\nAdditional user instruction:\n${USER_EXTENSION}` - && state.todoPlan === undefined, - "foreground user extension to persist as Goal revision 2", - diagnostics, - 30_000, - ) - lastState = revised - - assert.equal(provider.stats.revisionCalls, 1, "real executor must call user-revision tool exactly once") - assert.equal(provider.stats.sawRevisionTool, true, "custom user-revision tool was not exposed to the real OpenCode model request") - assert.equal(provider.stats.sawRevisionGuidance, true, "foreground user message did not carry revision guidance") - assert.equal(provider.stats.sawExactUserExtension, true, "foreground user extension was not preserved exactly in the model request") - - const replanned = await waitForGoal( - workspace, - (state) => state.sessionID === sessionID - && state.revision === 2 - && state.status === "active" - && state.todoPlan?.goalRevision === 2 - && state.todoPlan?.total === NEW_TODOS.length, - "fresh native Todo plan for Goal revision 2", - diagnostics, - 30_000, - ) - lastState = replanned - - assert.equal(provider.stats.sawRevisionToolResult, true, "real host did not feed the Goal revision tool result back into the foreground turn") - assert.equal(provider.stats.sawRevisedGoalPrompt, true, "Goal did not automatically continue with the revised durable objective") - assert.equal(provider.stats.revisedTodoCalls, 1, "revised Goal should rebuild its native Todo plan exactly once") - assert.equal(replanned.todoPlan.pending, 2) - assert.equal(replanned.todoPlan.inProgress, 1) - assert.equal(replanned.todoPlan.completed, 0) - assert.equal(replanned.stalledTurns, 0) - assert.equal(replanned.budget.maxTokens, 0, "new revisions must preserve the current unbounded cumulative token default") - - console.log(JSON.stringify({ - ok: true, - platform: process.platform, - sessionID, - revision: replanned.revision, - status: replanned.status, - oldTodoCalls: provider.stats.oldTodoCalls, - revisionCalls: provider.stats.revisionCalls, - revisedTodoCalls: provider.stats.revisedTodoCalls, - sawRevisionTool: provider.stats.sawRevisionTool, - sawRevisionGuidance: provider.stats.sawRevisionGuidance, - sawExactUserExtension: provider.stats.sawExactUserExtension, - sawRevisionToolResult: provider.stats.sawRevisionToolResult, - sawRevisedGoalPrompt: provider.stats.sawRevisedGoalPrompt, - todoPlan: replanned.todoPlan, - stalledTurns: replanned.stalledTurns, - }, null, 2)) - } finally { - await stopProcess(server) - await provider.close().catch(() => undefined) - await rm(workspace, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }).catch(() => undefined) - } -} - -main().catch((error) => { - console.error(error?.stack || error) - process.exitCode = 1 -}) From e8e225690dd84cbdb38b8a4f4dc158b548991e59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:51:37 +0300 Subject: [PATCH 14/18] Preserve durable part identity at Plan boundary --- src/opencode/agent-boundary.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/opencode/agent-boundary.ts b/src/opencode/agent-boundary.ts index b67e800..7960219 100644 --- a/src/opencode/agent-boundary.ts +++ b/src/opencode/agent-boundary.ts @@ -36,7 +36,16 @@ function textFromParts(parts: any[]): string { } function replaceParts(parts: any[], text: string) { - parts.splice(0, parts.length, { type: "text", text }) + // OpenCode 1.18+ persists chat.message parts as durable events. Replacing the + // host-owned part object with a freshly fabricated `{ type, text }` object + // drops its id/sessionID/messageID and makes the host reject the prompt before + // it can be saved. Reuse one existing durable text part and only replace its + // payload; discard sibling display parts without inventing new identities. + const durableText = parts.find((part) => part?.type === "text" && typeof part.text === "string") + if (!durableText) return false + durableText.text = text + parts.splice(0, parts.length, durableText) + return true } function withExecutionAgent(goal: GoalState, agent: string): GoalState { @@ -162,4 +171,4 @@ export function installRestrictedAgentSafety(input: PluginInput, hooks: PluginHo } await eventHook(eventInput) } -} +} \ No newline at end of file From d81e8ae7dd4b3ac807d9f58af43aee83041c9d29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:52:03 +0300 Subject: [PATCH 15/18] Lock Plan boundary durable part identity --- test/agent-boundary.test.mjs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/agent-boundary.test.mjs b/test/agent-boundary.test.mjs index 60c49e2..2823cfa 100644 --- a/test/agent-boundary.test.mjs +++ b/test/agent-boundary.test.mjs @@ -53,12 +53,23 @@ test("goal created from Plan is persisted paused instead of executing", async () const output = await runGoalCommand(hooks, sessionID, 'ship safely --success "tests pass" --constraint "do not change the public API"') assert.match(output.parts[0].text, /Continue working toward the active OpenCode goal/) + const durablePart = output.parts[0] + Object.assign(durablePart, { + id: "part-plan-create", + sessionID, + messageID: "plan-create-command", + }) await bindCommandMessage(hooks, sessionID, "plan-create-command", output, "plan") const goal = await store.load(sessionID) assert.equal(goal.status, "paused") assert.equal(goal.execution.agent, "plan") assert.match(goal.stopReason, /restricted agent "plan"/) + assert.equal(output.parts.length, 1) + assert.strictEqual(output.parts[0], durablePart, "Plan safety must rewrite the existing host-owned durable part instead of fabricating a new one") + assert.equal(output.parts[0].id, "part-plan-create") + assert.equal(output.parts[0].sessionID, sessionID) + assert.equal(output.parts[0].messageID, "plan-create-command") assert.match(output.parts[0].text, /Goal saved but paused in plan mode/) assert.match(output.parts[0].text, /continue analysis\/planning only/i) assert.equal(runtime.prompts.length, 0) @@ -142,4 +153,4 @@ test("startup recovery pauses an active persisted Plan Goal before any prompt", } finally { await rm(root, { recursive: true, force: true }) } -}) +}) \ No newline at end of file From b1a687217269ab1a3cbd86124fcf81488ba41670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:52:59 +0300 Subject: [PATCH 16/18] Document Goal steering and recovery semantics --- README.md | 48 ++++++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index a8f55aa..4882573 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ Pause and resume: /goal resume ``` -When a Goal is paused, a short explicit continuation message such as `devam et`, `continue`, or `resume` also resumes it through the same lifecycle control chain. Other normal chat does not silently reactivate a paused Goal. +When a Goal is paused, a short explicit continuation message such as `devam et`, `continue`, or `resume` resumes the **same revision** through the normal lifecycle control chain. A substantive foreground follow-up is different: when it clearly adds required work, the exact human message can be promoted into a new **extend** revision; when it clearly replaces the requested outcome, it can become a new **replace** revision. Questions, status/explanation requests, and ordinary same-scope steering do not rewrite the Goal contract. Queue future Goals: @@ -173,7 +173,8 @@ A single OpenCode **session has at most one unfinished live Goal**. This avoids If a Goal is already active or paused: -- use `/goal edit ` when you mean to revise the current Goal; +- use `/goal edit ` when you want an explicit deterministic rewrite of the current Goal; +- give a substantive foreground follow-up when you want the current Goal to absorb or replace scope naturally; material scope changes become a new revision before implementation continues; - use `/goal add ` to queue a second Goal for later; - use `/goal clear` if you intentionally want to abandon/archive the current Goal and start a different one; - use a **separate OpenCode session** when you intentionally want two Goals to run in parallel. @@ -190,15 +191,23 @@ For queued Goals: Separate sessions have separate persisted Goal snapshots. They can therefore run distinct Goals in the same project directory, although normal workspace conflicts are still possible if both sessions edit the same project files. -## Pause vs. normal chat: explicit continuation and arbitrary chat +## Pause, steering, and user-authorized Goal revisions -`/goal pause` changes persisted Goal state to `paused`. `/goal resume` remains the explicit lifecycle command for reactivating it. +`/goal pause` changes persisted Goal state to `paused`. `/goal resume` remains the explicit lifecycle command for reactivating the current revision. -For convenience, a narrow set of short, unambiguous continuation messages — for example `devam et`, `continue`, `kaldığın yerden devam et`, or `resume` — is treated as resume intent while a Goal is paused. The plugin routes that intent through the same `/goal resume` command/ownership chain rather than directly rewriting Goal state. +Foreground user messages are classified by intent rather than treating every message as either “resume” or “unrelated chat”: -Other foreground chat stays ordinary conversation and does **not** silently reactivate the Goal. This keeps arbitrary chat from becoming lifecycle control while letting a clear “continue” instruction do what the user expects. +- **Resume the same Goal:** short, unambiguous messages such as `devam et`, `continue`, `kaldığın yerden devam et`, or `resume` use the existing `/goal resume` ownership chain and keep the same revision. +- **Steer inside the existing scope:** clarifications or implementation guidance that already fit the current objective stay ordinary foreground steering; they do not rewrite the Goal contract. +- **Extend the scope:** a substantive message such as “also do these 100 items” can create a new revision that preserves the previous objective and appends the **exact latest human message** as additional required work. +- **Replace the scope:** a message that intentionally says to abandon/replace the old requested outcome can create a new revision whose objective is the **exact latest human message**. +- **Ask/explain/status:** questions such as “why did it stop?” or “what is left?” do not change Goal status, scope, or revision. -The same resume path can be used after a fail-closed verifier outage. A timeout-class verifier failure first receives one fresh bounded automatic retry; if that retry also fails and the Goal is persisted as `paused`, wait until the verifier/provider is usable and then use `/goal resume` or a short explicit continuation message to retry completion. +Material scope revisions are host-authorized: the model may choose whether the latest message means extend or replace, but it cannot invent or summarize replacement objective text. Only the exact foreground human message that directly parented that assistant turn can be consumed, once. The revision resets stale native Todo telemetry so the next Goal-owned turn builds a fresh plan, while cumulative usage, budgets, and historical evidence remain preserved. The stale pre-revision assistant turn is not allowed to keep mutating the workspace after the revision boundary. + +`budget_limited`, `usage_limited`, and completed states are not implicitly bypassed by foreground chat. Use the explicit Goal budget/lifecycle controls when those states require intervention. `/goal edit` remains available whenever you want deterministic manual control over the exact resulting objective. + +Transient verifier/provider/network failures are different from a user pause. Since 1.3.26, retryable infrastructure failures use persisted recovery/backoff rather than normally requiring a manual `/goal resume`: Goal respects host `retry`/`busy`/unknown ownership, backs off from 15 seconds up to a five-minute cap, survives process restarts, and avoids spending the normal no-progress budget on infrastructure failure. A short resume command remains useful for an actual user pause or compatible legacy state, but it is not the normal recovery mechanism for a current retryable outage. ## Goal Contracts @@ -221,7 +230,7 @@ New Goals have no cumulative token cap by default (`maxTokens: 0`). Use `--max-t The full objective always remains a required semantic requirement. Narrow checks add proof obligations; they never replace the broader outcome. -`/goal edit` creates a new revision. Evidence from an older revision cannot silently prove the edited Goal. +`/goal edit` and material foreground scope changes create a new revision. Evidence from an older revision cannot silently prove the edited/rebased Goal. ## Multi-turn cadence and anti-batching @@ -248,7 +257,8 @@ The boundary is strict: - Todo cannot widen the user-authorized Goal scope; - a current Todo plan with `pending` or `in_progress` work vetoes completion; - a fully completed Todo plan still does **not** prove the Goal; -- missing or stale Todo telemetry cannot block a newer Goal revision. +- missing or stale Todo telemetry cannot block a newer Goal revision; +- a material user-authorized Goal revision discards the stale Todo snapshot so the next Goal-owned turn must re-plan the new revision. ## Completion integrity @@ -265,19 +275,13 @@ Completion is an audit pipeline: If verification is unavailable, incomplete, stale, ambiguous, or races with a lifecycle change, completion **fails closed**. -### Verifier timeout / bounded retry / Goal stays paused +### Verifier/provider infrastructure recovery -If the executor has finished the work but independent semantic verification hits a timeout-class infrastructure failure, the plugin aborts and cleans up that verifier child and automatically retries **once** in a fresh verifier session. The retry is capped at 60 seconds, or at the configured verifier timeout when that is lower. There is no third automatic verifier attempt. +A timeout-class semantic-verifier failure still gets one fresh bounded verifier retry after the failed verifier child is aborted and cleaned up. If verification or the provider remains unavailable for a retryable infrastructure reason, current releases do **not** normally convert that temporary outage into a permanent manual pause. The Goal records persisted infrastructure-recovery state and retries with exponential cooldown starting at 15 seconds and capped at five minutes. -Non-timeout provider or transport failures are not automatically retried. If the bounded timeout retry also fails, the Goal is persisted as `paused` instead of entering an endless completion retry loop. Existing host evidence remains persisted. +The recovery coordinator also covers retryable provider/transport failures such as transient fetch/network errors, `ECONNRESET`, `ENOTFOUND`, `EAI_AGAIN`, and `ETIMEDOUT`. While OpenCode reports `retry`, `busy`, or an unknown/non-idle ownership state, Goal does not inject a competing autonomous prompt. A bounded watchdog exists for older hosts that can remain stuck in retry, and the recovery state survives a process restart. -When the verifier/provider is healthy again: - -```text -/goal resume -``` - -A short explicit continuation message such as `continue` or `devam et` uses the same resume path. +Infrastructure recovery does not prove completion and does not spend the normal no-progress/stall budget. Fatal authentication/configuration failures and explicit host usage limits remain fail-closed and require the appropriate user/configuration action. A verifier outage never marks an unproven Goal completed. @@ -326,7 +330,11 @@ Check: /goal audit ``` -If the stop reason is verifier infrastructure/timeout after the bounded automatic retry and the workspace is already correct, do not manually repeat the requested mutations. Use `/goal resume` or a short explicit continuation message to retry the completion path. +If `/goal status` reports current infrastructure recovery, let the persisted recovery/backoff path retry; do not manually repeat already-correct workspace mutations. If the Goal is genuinely user-paused, restored from a compatible legacy state, or otherwise eligible for manual reactivation, use `/goal resume` or a short explicit continuation message. Fatal authentication/configuration errors and explicit usage/budget limits must be fixed explicitly rather than bypassed by resume/revision chat. + +### I gave the paused Goal a large new list but it did not belong to the old plan + +You can send the new requirements as ordinary foreground text. When they materially add work, Goal creates an extend revision from the exact message and rebuilds native Todo planning on the next Goal-owned turn. If you explicitly replace the old outcome, it creates a replace revision instead. Use `/goal edit ` when you want to force an exact manual rewrite. ### I cannot start another Goal in the same session From d75ad535e16ef552eb597a344a7ebe20b2c2a17b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:53:44 +0300 Subject: [PATCH 17/18] =?UTF-8?q?Goal=20steering=20ve=20recovery=20davran?= =?UTF-8?q?=C4=B1=C5=9F=C4=B1n=C4=B1=20belgeleyin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.tr.md | 48 ++++++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/README.tr.md b/README.tr.md index c745544..8f0cc69 100644 --- a/README.tr.md +++ b/README.tr.md @@ -123,7 +123,7 @@ Duraklatın ve devam ettirin: /goal resume ``` -Goal paused durumundayken `devam et`, `continue` veya `resume` gibi kısa ve açık bir devam mesajı da aynı lifecycle control zinciri üzerinden Goal'ı yeniden aktive eder. Diğer normal chat mesajları paused Goal'ı sessizce yeniden başlatmaz. +Goal paused durumundayken `devam et`, `continue` veya `resume` gibi kısa ve açık bir mesaj normal lifecycle zinciri üzerinden **aynı revision'ı** devam ettirir. Ciddi bir foreground follow-up ise farklıdır: mesaj açıkça yeni zorunlu işler ekliyorsa kullanıcının yazdığı metnin birebir hali yeni bir **extend** revision'ına taşınabilir; eski sonucu açıkça bırakıp başka sonucu istiyorsa yeni bir **replace** revision'ı oluşturulabilir. Soru, durum/açıklama isteği ve mevcut scope içindeki normal steering Goal contract'ını yeniden yazmaz. Gelecekteki Goal'ları sıraya ekleyin: @@ -173,7 +173,8 @@ Tek bir OpenCode **session'ında en fazla bir bitmemiş canlı Goal** bulunur. B Bir Goal zaten active veya paused durumdaysa: -- mevcut Goal'ı revize etmek istiyorsanız `/goal edit ` kullanın; +- mevcut Goal'ı tam ve deterministik biçimde yeniden yazmak istiyorsanız `/goal edit ` kullanın; +- mevcut Goal'ın yeni işi doğal biçimde kapsamasını veya eski sonucu değiştirmesini istiyorsanız ciddi foreground follow-up gönderin; material scope değişikliği implementation sürmeden önce yeni revision olur; - ikinci Goal'ı daha sonra çalıştırmak için `/goal add ` kullanın; - mevcut Goal'ı bilerek bırakıp/arşivleyip başka bir Goal başlatmak istiyorsanız `/goal clear` kullanın; - iki Goal'ı bilinçli olarak paralel çalıştırmak istiyorsanız **ayrı bir OpenCode session'ı** kullanın. @@ -190,15 +191,23 @@ Queue için: Ayrı session'lar ayrı kalıcı Goal snapshot'larına sahiptir. Bu nedenle aynı proje dizininde farklı Goal'lar çalıştırabilirler; ancak iki session aynı proje dosyalarını değiştirirse normal workspace çakışmaları yine oluşabilir. -## Pause ile normal chat farkı: açık devam isteği ve sıradan chat +## Pause, steering ve kullanıcı yetkili Goal revision'ları -`/goal pause`, kalıcı Goal durumunu `paused` yapar. `/goal resume`, Goal'ı yeniden aktive etmek için açık lifecycle komutu olarak kalır. +`/goal pause`, kalıcı Goal durumunu `paused` yapar. `/goal resume`, mevcut revision'ı yeniden aktive etmek için açık lifecycle komutu olarak kalır. -Kolaylık için `devam et`, `continue`, `kaldığın yerden devam et` veya `resume` gibi kısa ve belirsiz olmayan devam mesajları, Goal paused durumundayken resume niyeti olarak kabul edilir. Plugin bu niyeti Goal state'ini doğrudan değiştirmek yerine mevcut `/goal resume` command/ownership zinciri üzerinden geçirir. +Foreground kullanıcı mesajları artık her şeyi “resume” veya “ilgisiz chat” diye ikiye ayırmak yerine niyetine göre değerlendirilir: -Diğer foreground chat mesajları normal konuşma olarak kalır ve Goal'ı **sessizce yeniden aktive etmez**. Böylece rastgele chat lifecycle control'e dönüşmezken açık bir “devam et” isteği beklenen davranışı verir. +- **Aynı Goal'a devam:** `devam et`, `continue`, `kaldığın yerden devam et` veya `resume` gibi kısa ve belirsiz olmayan mesajlar mevcut `/goal resume` ownership zincirini kullanır ve revision değişmez. +- **Mevcut scope içinde steering:** mevcut objective'in zaten kapsadığı açıklama, öncelik veya implementation yönlendirmesi normal foreground steering olarak kalır; Goal contract yeniden yazılmaz. +- **Scope'u genişlet:** “bunlara ek olarak şu 100 maddeyi de yap” gibi ciddi bir mesaj, eski objective'i koruyup **son insan mesajının birebir halini** additional required work olarak ekleyen yeni revision oluşturabilir. +- **Scope'u değiştir:** eski sonucu açıkça bırakıp yerine başka sonuç isteyen mesaj, objective'i **son insan mesajının birebir hali** olan yeni revision oluşturabilir. +- **Soru/açıklama/status:** “neden durdu?” veya “ne kaldı?” gibi mesajlar Goal status, scope veya revision değiştirmez. -Aynı resume yolu fail-closed verifier kesintisi sonrasında da kullanılabilir. Timeout sınıfındaki bir verifier hatası önce taze bir verifier session'ında tek ve bounded bir otomatik retry alır; bu retry de başarısız olur ve Goal `paused` olarak kalıcılaştırılırsa verifier/provider kullanılabilir olduğunda `/goal resume` veya kısa ve açık bir devam mesajıyla completion yeniden denenebilir. +Material scope revision'ları host tarafından yetkilendirilir: model son mesajın extend mi replace mi olduğuna karar verebilir, fakat yeni objective metnini kendi yazamaz, özetleyemez veya 100 maddeden bazılarını düşüremez. Yalnızca o assistant turn'ünün doğrudan parent'ı olan exact foreground human message tek kez kullanılabilir. Revision, stale native Todo telemetry'yi temizler; sonraki Goal-owned turn yeni revision için taze plan kurar. Cumulative usage, budget'lar ve geçmiş evidence korunur. Revision boundary'den sonra stale pre-revision assistant turn workspace'i değiştirmeye devam edemez. + +`budget_limited`, `usage_limited` ve completed durumları foreground chat ile sessizce bypass edilmez. Bu durumlarda gerekli açık Goal budget/lifecycle kontrolünü kullanın. Son objective üzerinde deterministik manuel kontrol istediğinizde `/goal edit` her zaman kullanılabilir. + +Transient verifier/provider/network hataları kullanıcı pause'undan farklıdır. 1.3.26'dan beri retry edilebilir altyapı hataları normalde manuel `/goal resume` gerektiren kalıcı pause'a dönüşmez: Goal host `retry`/`busy`/unknown ownership durumuna saygı gösterir, 15 saniyeden başlayıp beş dakikaya kadar çıkan persisted backoff kullanır, restart sonrasında recovery state'i korur ve altyapı hatasını normal no-progress bütçesinden yemez. Kısa resume mesajı gerçek user pause veya uyumlu eski state için faydalıdır; güncel retryable outage için normal recovery mekanizması değildir. ## Goal Contracts @@ -221,7 +230,7 @@ Yeni Goal'larda cumulative token limiti varsayılan olarak yoktur (`maxTokens: 0 Tam objective her zaman gerekli bir semantic requirement olarak kalır. Dar kapsamlı kontroller ek proof obligations oluşturur; geniş sonucu asla değiştirmez veya yerine geçmez. -`/goal edit` yeni bir revision oluşturur. Eski revision'a ait kanıtlar düzenlenmiş Goal'ı sessizce kanıtlayamaz. +`/goal edit` ve material foreground scope değişiklikleri yeni revision oluşturur. Eski revision'a ait kanıtlar düzenlenmiş/rebase edilmiş Goal'ı sessizce kanıtlayamaz. ## Multi-turn cadence ve anti-batching @@ -248,7 +257,8 @@ Sınır nettir: - Todo, kullanıcının yetki verdiği Goal scope'unu genişletemez; - güncel Todo planında `pending` veya `in_progress` iş varsa completion veto edilir; - tamamen bitmiş Todo planı bile Goal'ı kanıtlamaz; -- eksik veya stale Todo telemetry daha yeni Goal revision'ını engelleyemez. +- eksik veya stale Todo telemetry daha yeni Goal revision'ını engelleyemez; +- material kullanıcı yetkili Goal revision'ı stale Todo snapshot'ını temizler ve sonraki Goal-owned turn yeni revision'ı yeniden planlar. ## Completion bütünlüğü @@ -265,19 +275,13 @@ Completion bir audit pipeline'ıdır: Verification kullanılamıyorsa, eksikse, stale/ambiguous ise veya lifecycle değişikliğiyle race oluşursa completion **fail closed** olur. -### Verifier timeout / bounded retry / Goal paused kalıyor +### Verifier/provider altyapı recovery -Executor işi bitirmiş olsa bile bağımsız semantic verification timeout sınıfındaki bir altyapı hatasına ulaşırsa plugin timeout olan verifier child'ını abort edip temizler ve **bir kez** taze verifier session'ıyla otomatik retry yapar. Bu retry en fazla 60 saniyedir; yapılandırılmış verifier timeout daha düşükse o düşük değer kullanılır. Üçüncü bir otomatik verifier denemesi yoktur. +Timeout sınıfındaki semantic-verifier hatası, başarısız verifier child abort edilip temizlendikten sonra hâlâ bir taze bounded verifier retry alır. Verification veya provider retry edilebilir bir altyapı nedeniyle kullanılamamaya devam ederse güncel sürümler bu geçici kesintiyi normalde kalıcı manuel pause'a çevirmek yerine persisted infrastructure-recovery state yazar ve 15 saniyeden başlayıp beş dakikada tavan yapan exponential cooldown ile tekrar dener. -Timeout dışındaki provider veya transport hataları otomatik retry edilmez. Bounded timeout retry da başarısız olursa Goal sonsuz completion retry döngüsüne girmek yerine `paused` olarak kalıcılaştırılır. Mevcut host evidence korunur. +Recovery coordinator; transient fetch/network hataları, `ECONNRESET`, `ENOTFOUND`, `EAI_AGAIN` ve `ETIMEDOUT` gibi retry edilebilir provider/transport hatalarını da kapsar. OpenCode `retry`, `busy` veya unknown/non-idle ownership bildirirken Goal ikinci bir autonomous prompt basmaz. Eski host'ların retry durumunda takılabilmesi için bounded watchdog vardır ve recovery state process restart sonrasında korunur. -Verifier/provider tekrar sağlıklı olduğunda: - -```text -/goal resume -``` - -kullanın. `devam et` veya `continue` gibi kısa ve açık bir mesaj da aynı resume yolunu kullanır. +Infrastructure recovery completion'ı kanıtlamaz ve normal no-progress/stall bütçesini harcamaz. Fatal authentication/configuration hataları ile açık host usage limitleri fail-closed kalır ve uygun kullanıcı/configuration müdahalesi ister. Verifier kesintisi kanıtlanmamış bir Goal'ı hiçbir zaman completed olarak işaretlemez. @@ -326,7 +330,11 @@ Installer kullanıcıya ait bir `commands/goal.md` dosyasının üzerine yazmaz. /goal audit ``` -Stop reason bounded otomatik retry sonrasında verifier infrastructure/timeout ise ve workspace zaten doğruysa istenen mutation'ları elle tekrar etmeyin. Completion yolunu yeniden denemek için `/goal resume` veya kısa ve açık bir devam mesajı kullanın. +`/goal status` güncel infrastructure recovery gösteriyorsa persisted recovery/backoff yolunun retry etmesine izin verin; zaten doğru olan workspace mutation'larını elle tekrarlamayın. Goal gerçekten kullanıcı tarafından pause edildiyse, uyumlu eski bir state'ten restore edildiyse veya başka şekilde manuel reactivation için uygunsa `/goal resume` ya da kısa ve açık devam mesajı kullanın. Fatal authentication/configuration hataları ile açık usage/budget limitleri resume/revision chat ile bypass edilmemeli; nedeni doğrudan düzeltilmelidir. + +### Paused Goal'a büyük bir yeni liste verdim ama eski plan bunu kapsamıyor + +Yeni requirements'ı normal foreground metni olarak gönderebilirsiniz. Material olarak iş ekliyorsa Goal exact mesajdan extend revision oluşturur ve sonraki Goal-owned turn'de native Todo planını yeniden kurar. Eski sonucu açıkça değiştiriyorsanız replace revision oluşturur. Son objective'i birebir kendiniz belirlemek istiyorsanız `/goal edit ` kullanın. ### Aynı session'da başka Goal başlatamıyorum From 5a1c99d9e680750cca289c7e9f75f99230bb3e32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 12:56:01 +0300 Subject: [PATCH 18/18] Model real Plan command bridge without autonomous wake --- scripts/host-user-revision-plan-canary.mjs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/host-user-revision-plan-canary.mjs b/scripts/host-user-revision-plan-canary.mjs index c169a5d..080a47e 100644 --- a/scripts/host-user-revision-plan-canary.mjs +++ b/scripts/host-user-revision-plan-canary.mjs @@ -233,6 +233,7 @@ function startProvider() { const stats = { chatRequests: 0, paths: [], + planBoundaryRequests: 0, revisionCalls: 0, revisedTodoCalls: 0, sawRevisionTool: false, @@ -268,6 +269,12 @@ function startProvider() { const priorTools = priorToolCallNames(body) stats.sawRevisionTool ||= tools.has("opencode_goal_revise_from_user") + if (lastUser.includes("Goal saved but paused in plan mode") && lastUser.includes(OLD_OBJECTIVE)) { + stats.planBoundaryRequests += 1 + streamText(res, { id, created, content: "PLAN_BOUNDARY_ACK" }) + return + } + if (lastUser.includes(USER_EXTENSION)) { stats.sawExactUserExtension ||= USER_EXTENSION.split("\n").every((line) => lastUser.includes(line)) stats.sawRevisionGuidance ||= lastUser.includes("") @@ -457,7 +464,11 @@ async function main() { ) lastState = paused await goalCommand - assert.equal(provider.stats.chatRequests, 0, "Plan Goal creation must not start autonomous model execution") + assert.equal(provider.stats.planBoundaryRequests, 1, "the slash-command bridge should materialize exactly one Plan boundary model turn") + assert.equal(provider.stats.chatRequests, 1, "paused Plan Goal must not autonomously continue after its command-owned boundary turn") + await new Promise((resolve) => setTimeout(resolve, 250)) + assert.equal(provider.stats.chatRequests, 1, "paused Plan Goal emitted an unexpected autonomous continuation") + assert.equal(provider.stats.revisionCalls, 0) await api("foreground Build extension", `/session/${encodeURIComponent(sessionID)}/prompt_async`, { method: "POST", @@ -517,6 +528,7 @@ async function main() { revision: replanned.revision, status: replanned.status, executionAgent: replanned.execution?.agent, + planBoundaryRequests: provider.stats.planBoundaryRequests, revisionCalls: provider.stats.revisionCalls, revisedTodoCalls: provider.stats.revisedTodoCalls, sawRevisionTool: provider.stats.sawRevisionTool,