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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Config } from "@/config/config"
import { MCP } from "../mcp"
import { Skill } from "../skill"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_LOOP from "./template/loop.txt"
import PROMPT_REVIEW from "./template/review.txt"
import { LegacyEvent } from "@opencode-ai/schema/legacy-event"

Expand Down Expand Up @@ -45,6 +46,8 @@ export function hints(template: string) {

export const Default = {
INIT: "init",
LOOP: "loop",
PROACTIVE: "proactive",
REVIEW: "review",
} as const

Expand Down Expand Up @@ -86,6 +89,24 @@ const layer = Layer.effect(
subtask: true,
hints: hints(PROMPT_REVIEW),
}
commands[Default.LOOP] = {
name: Default.LOOP,
description: "run a prompt repeatedly until it finishes or you stop it",
source: "command",
get template() {
return PROMPT_LOOP
},
hints: ["$ARGUMENTS"],
}
commands[Default.PROACTIVE] = {
name: Default.PROACTIVE,
description: "alias for /loop",
source: "command",
get template() {
return PROMPT_LOOP
},
hints: ["$ARGUMENTS"],
}

for (const [name, command] of Object.entries(cfg.command ?? {})) {
commands[name] = {
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/src/command/template/loop.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Run a prompt repeatedly while the session stays open.

Usage:
- `/loop <prompt>` starts a self-paced loop.
- `/loop 5m <prompt>` starts a scheduled loop.
- `/loop stop` stops the active loop for the session.

If no prompt is provided, use `.claude/loop.md` from the project when present.
306 changes: 305 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ IMPORTANT:

const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.`

const LOOP_CONTINUE_MARKER = "<loop:continue>"
const LOOP_STOP_MARKER = "<loop:stop>"
const LOOP_SELF_PACED_DELAY_MS = 1000

type LoopCommandParsed =
| { action: "stop" }
| {
action: "start"
intervalMs?: number
prompt: string
}

type LoopState = {
stop: () => void
}

function mcpResourceBase64Size(value: string) {
const trimmed = value.replace(/\s/g, "")
const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0
Expand Down Expand Up @@ -151,9 +167,180 @@ const layer = Layer.effect(

const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
yield* Effect.logInfo("cancel", { "session.id": sessionID })
stopLoop(sessionID)
yield* state.cancel(sessionID)
})

const activeLoops = new Map<SessionID, LoopState>()

yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const loop of activeLoops.values()) loop.stop()
activeLoops.clear()
}),
)

const createSyntheticReply = Effect.fn("SessionPrompt.createSyntheticReply")(function* (input: {
sessionID: SessionID
userText: string
assistantText: string
agent: string
model: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
variant?: string
}) {
const ctx = yield* InstanceState.context
const userMsg: SessionV1.User = {
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: input.agent,
model: {
providerID: input.model.providerID,
modelID: input.model.modelID,
...(input.variant ? { variant: input.variant } : {}),
},
}
yield* sessions.updateMessage(userMsg)
yield* sessions.updatePart({
id: PartID.ascending(),
type: "text",
messageID: userMsg.id,
sessionID: input.sessionID,
text: input.userText,
})

const assistant: SessionV1.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID: input.sessionID,
parentID: userMsg.id,
mode: input.agent,
agent: input.agent,
cost: 0,
path: { cwd: ctx.directory, root: ctx.worktree },
time: { created: Date.now(), completed: Date.now() },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: input.model.modelID,
providerID: input.model.providerID,
...(input.variant ? { variant: input.variant } : {}),
finish: "stop",
}
yield* sessions.updateMessage(assistant)
const part: SessionV1.TextPart = {
id: PartID.ascending(),
type: "text",
messageID: assistant.id,
sessionID: input.sessionID,
text: input.assistantText,
}
yield* sessions.updatePart(part)
return { info: assistant, parts: [part] }
})

const stopLoop = (sessionID: SessionID) => {
const loop = activeLoops.get(sessionID)
if (!loop) return false
loop.stop()
activeLoops.delete(sessionID)
return true
}

const readLoopPrompt = Effect.fn("SessionPrompt.readLoopPrompt")(function* () {
const ctx = yield* InstanceState.context
const filepath = path.join(ctx.worktree, ".claude", "loop.md")
const exists = yield* fsys.exists(filepath).pipe(Effect.orDie)
if (!exists) return undefined
const content = (yield* fsys.readFileString(filepath).pipe(Effect.orDie)).trim()
return content || undefined
})

const normalizeLoopResult = Effect.fn("SessionPrompt.normalizeLoopResult")(function* (result: SessionV1.WithParts) {
const textPart = result.parts.findLast((part): part is SessionV1.TextPart => part.type === "text")
if (!textPart) return { continueLoop: false, result }
const parsed = extractLoopControl(textPart.text)
if (parsed.text === textPart.text) return { continueLoop: false, result }
textPart.text = parsed.text
yield* sessions.updatePart(textPart)
return { continueLoop: parsed.action === "continue", result }
})

const loopStep = Effect.fn("SessionPrompt.loopStep")(function* (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
variant?: string
promptText: string
parts?: CommandInput["parts"]
}) {
const result = yield* prompt({
sessionID: input.sessionID,
messageID: MessageID.ascending(),
agent: input.agent,
model: input.model,
variant: input.variant,
parts: [{ type: "text", text: input.promptText }, ...(input.parts ?? [])],
})
return yield* normalizeLoopResult(result)
})

const startLoop = Effect.fn("SessionPrompt.startLoop")(function* (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
variant?: string
promptText: string
intervalMs?: number
}) {
stopLoop(input.sessionID)
let stopped = false

const delay = input.intervalMs ?? LOOP_SELF_PACED_DELAY_MS

const fiber = yield* Effect.gen(function* () {
while (!stopped) {
const exit = yield* loopStep({
sessionID: input.sessionID,
agent: input.agent,
model: input.model,
variant: input.variant,
promptText: input.promptText,
}).pipe(Effect.exit)

if (stopped) return

if (Exit.isSuccess(exit)) {
if (!exit.value.continueLoop) {
stopLoop(input.sessionID)
return
}
} else {
const error = Cause.squash(exit.cause)
if (error instanceof Session.BusyError) {
// continue waiting
} else {
stopLoop(input.sessionID)
yield* events.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: new NamedError.Unknown({
message: error instanceof Error ? error.message : String(error),
}).toObject(),
})
return
}
}

yield* Effect.sleep(delay)
}
}).pipe(Effect.forkIn(scope))

activeLoops.set(input.sessionID, {
stop: () => {
stopped = true
},
})
})

const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
const ctx = yield* InstanceState.context
const parts: Types.DeepMutable<PromptInput["parts"]> = [{ type: "text", text: template }]
Expand Down Expand Up @@ -1367,7 +1554,60 @@ const layer = Layer.effect(
yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
throw error
}
const agentName = cmd.agent ?? input.agent
const agentName = cmd.agent ?? input.agent ?? (yield* agents.defaultInfo()).name

if (input.command === Command.Default.LOOP || input.command === Command.Default.PROACTIVE) {
const taskModel = input.model ? Provider.parseModel(input.model) : yield* currentModel(input.sessionID)
yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID)
const parsed = parseLoopCommandArguments(input.arguments)
if (parsed.action === "stop") {
const stopped = stopLoop(input.sessionID)
const result = yield* createSyntheticReply({
sessionID: input.sessionID,
userText: `/${input.command} stop`,
assistantText: stopped ? "Stopped the active loop." : "No active loop is running for this session.",
agent: agentName,
model: taskModel,
variant: input.variant,
}).pipe(Effect.orDie)
yield* events.publish(Command.Event.Executed, {
name: input.command,
sessionID: input.sessionID,
arguments: input.arguments,
messageID: result.info.id,
})
return result
}

const loopPrompt = parsed.prompt || (yield* readLoopPrompt().pipe(Effect.orDie)) || DEFAULT_LOOP_PROMPT
const first = yield* loopStep({
sessionID: input.sessionID,
agent: agentName,
model: taskModel,
variant: input.variant,
promptText: loopPrompt,
parts: input.parts,
}).pipe(Effect.orDie)
if (first.continueLoop) {
yield* startLoop({
sessionID: input.sessionID,
agent: agentName,
model: taskModel,
variant: input.variant,
promptText: loopPrompt,
intervalMs: parsed.intervalMs,
}).pipe(Effect.orDie)
} else {
stopLoop(input.sessionID)
}
yield* events.publish(Command.Event.Executed, {
name: input.command,
sessionID: input.sessionID,
arguments: input.arguments,
messageID: first.result.info.id,
})
return first.result
}

const raw = input.arguments.match(argsRegex) ?? []
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
Expand Down Expand Up @@ -1561,6 +1801,70 @@ export const CommandInput = Schema.Struct({
})
export type CommandInput = Schema.Schema.Type<typeof CommandInput>

const durationRegex = /^([1-9]\d*)(s|m|h|d)$/i
export const DEFAULT_LOOP_PROMPT = [
"You are running in loop mode.",
"",
"Perform proactive maintenance for the current session and project.",
"If there is nothing useful to do right now, say what you checked and stop.",
"",
`End your final text response with exactly one control marker on its own line: ${LOOP_CONTINUE_MARKER} if another loop iteration should run, or ${LOOP_STOP_MARKER} if the work is complete or blocked until the user or an external system changes state.`,
].join("\n")

export function parseLoopCommandArguments(input: string): LoopCommandParsed {
const trimmed = input.trim()
if (!trimmed) return { action: "start", prompt: "" }
if (/^(stop|off)$/i.test(trimmed)) return { action: "stop" }
const [first, ...rest] = trimmed.split(/\s+/)
const match = first.match(durationRegex)
if (!match) return { action: "start", prompt: buildLoopPrompt(trimmed) }
const value = Number(match[1])
const unit = match[2].toLowerCase()
const intervalMs =
unit === "s"
? value * 1000
: unit === "m"
? value * 60 * 1000
: unit === "h"
? value * 60 * 60 * 1000
: value * 24 * 60 * 60 * 1000
return {
action: "start",
intervalMs,
prompt: rest.length > 0 ? buildLoopPrompt(rest.join(" ")) : "",
}
}

export function buildLoopPrompt(goal: string) {
const trimmed = goal.trim()
return [
"You are running in loop mode.",
"",
trimmed ? `Goal:\n${trimmed}` : "Perform proactive maintenance for the current session and project.",
"",
"Work autonomously until the goal is complete or you are blocked by the user, permissions, or an external dependency.",
"Re-check relevant state before stopping so you do not miss new changes.",
`End your final text response with exactly one control marker on its own line: ${LOOP_CONTINUE_MARKER} if another loop iteration should run, or ${LOOP_STOP_MARKER} if the work is complete or blocked until the user or an external system changes state.`,
].join("\n")
}

export function extractLoopControl(text: string): { action: "continue" | "stop"; text: string } {
const trimmedEnd = text.replace(/\s+$/, "")
if (trimmedEnd.endsWith(LOOP_CONTINUE_MARKER)) {
return {
action: "continue",
text: trimmedEnd.slice(0, -LOOP_CONTINUE_MARKER.length).replace(/\s+$/, ""),
}
}
if (trimmedEnd.endsWith(LOOP_STOP_MARKER)) {
return {
action: "stop",
text: trimmedEnd.slice(0, -LOOP_STOP_MARKER.length).replace(/\s+$/, ""),
}
}
return { action: "stop", text }
}

/** @internal Exported for testing */
export function createStructuredOutputTool(input: {
schema: Record<string, any>
Expand Down
Loading
Loading