diff --git a/src/hooks/ralph-loop/completion-promise-detector.test.ts b/src/hooks/ralph-loop/completion-promise-detector.test.ts
new file mode 100644
index 00000000..7f630782
--- /dev/null
+++ b/src/hooks/ralph-loop/completion-promise-detector.test.ts
@@ -0,0 +1,105 @@
+///
+import { describe, expect, test } from "bun:test"
+import type { PluginInput } from "@opencode-ai/plugin"
+import { detectCompletionInSessionMessages } from "./completion-promise-detector"
+
+type SessionMessage = {
+ info?: { role?: string }
+ parts?: Array<{ type: string; text?: string }>
+}
+
+function createPluginInput(messages: SessionMessage[]): PluginInput {
+ return {
+ client: {
+ session: {
+ messages: async () => ({ data: messages }),
+ },
+ },
+ } as PluginInput
+}
+
+describe("detectCompletionInSessionMessages", () => {
+ describe("#given session with prior DONE and new messages", () => {
+ test("#when sinceMessageIndex excludes prior DONE #then should NOT detect completion", async () => {
+ // #given
+ const messages: SessionMessage[] = [
+ {
+ info: { role: "assistant" },
+ parts: [{ type: "text", text: "Old completion DONE" }],
+ },
+ {
+ info: { role: "assistant" },
+ parts: [{ type: "text", text: "Working on the new task" }],
+ },
+ ]
+ const ctx = createPluginInput(messages)
+
+ // #when
+ const detected = await detectCompletionInSessionMessages(ctx, {
+ sessionID: "session-123",
+ promise: "DONE",
+ apiTimeoutMs: 1000,
+ directory: "/tmp",
+ sinceMessageIndex: 1,
+ })
+
+ // #then
+ expect(detected).toBe(false)
+ })
+
+ test("#when sinceMessageIndex includes current DONE #then should detect completion", async () => {
+ // #given
+ const messages: SessionMessage[] = [
+ {
+ info: { role: "assistant" },
+ parts: [{ type: "text", text: "Old completion DONE" }],
+ },
+ {
+ info: { role: "assistant" },
+ parts: [{ type: "text", text: "Current completion DONE" }],
+ },
+ ]
+ const ctx = createPluginInput(messages)
+
+ // #when
+ const detected = await detectCompletionInSessionMessages(ctx, {
+ sessionID: "session-123",
+ promise: "DONE",
+ apiTimeoutMs: 1000,
+ directory: "/tmp",
+ sinceMessageIndex: 1,
+ })
+
+ // #then
+ expect(detected).toBe(true)
+ })
+ })
+
+ describe("#given no sinceMessageIndex (backward compat)", () => {
+ test("#then should scan all messages", async () => {
+ // #given
+ const messages: SessionMessage[] = [
+ {
+ info: { role: "assistant" },
+ parts: [{ type: "text", text: "Old completion DONE" }],
+ },
+ {
+ info: { role: "assistant" },
+ parts: [{ type: "text", text: "No completion in latest message" }],
+ },
+ ]
+ const ctx = createPluginInput(messages)
+
+ // #when
+ const detected = await detectCompletionInSessionMessages(ctx, {
+ sessionID: "session-123",
+ promise: "DONE",
+ apiTimeoutMs: 1000,
+ directory: "/tmp",
+ })
+
+ // #then
+ expect(detected).toBe(true)
+ })
+ })
+})
diff --git a/src/hooks/ralph-loop/completion-promise-detector.ts b/src/hooks/ralph-loop/completion-promise-detector.ts
index 95a43c28..91409153 100644
--- a/src/hooks/ralph-loop/completion-promise-detector.ts
+++ b/src/hooks/ralph-loop/completion-promise-detector.ts
@@ -52,6 +52,7 @@ export async function detectCompletionInSessionMessages(
promise: string
apiTimeoutMs: number
directory: string
+ sinceMessageIndex?: number
},
): Promise {
try {
@@ -75,7 +76,12 @@ export async function detectCompletionInSessionMessages(
? responseData
: []
- const assistantMessages = (messageArray as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant")
+ const scopedMessages =
+ typeof options.sinceMessageIndex === "number" && options.sinceMessageIndex >= 0 && options.sinceMessageIndex < messageArray.length
+ ? messageArray.slice(options.sinceMessageIndex)
+ : messageArray
+
+ const assistantMessages = (scopedMessages as OpenCodeSessionMessage[]).filter((msg) => msg.info?.role === "assistant")
if (assistantMessages.length === 0) return false
const pattern = buildPromisePattern(options.promise)
diff --git a/src/hooks/ralph-loop/loop-state-controller.ts b/src/hooks/ralph-loop/loop-state-controller.ts
index ab0ad39a..6e6602df 100644
--- a/src/hooks/ralph-loop/loop-state-controller.ts
+++ b/src/hooks/ralph-loop/loop-state-controller.ts
@@ -23,6 +23,7 @@ export function createLoopStateController(options: {
loopOptions?: {
maxIterations?: number
completionPromise?: string
+ messageCountAtStart?: number
ultrawork?: boolean
strategy?: "reset" | "continue"
},
@@ -34,6 +35,7 @@ export function createLoopStateController(options: {
loopOptions?.maxIterations ??
config?.default_max_iterations ??
DEFAULT_MAX_ITERATIONS,
+ message_count_at_start: loopOptions?.messageCountAtStart,
completion_promise:
loopOptions?.completionPromise ??
DEFAULT_COMPLETION_PROMISE,
@@ -93,5 +95,19 @@ export function createLoopStateController(options: {
return state
},
+
+ setMessageCountAtStart(sessionID: string, messageCountAtStart: number): RalphLoopState | null {
+ const state = readState(directory, stateDir)
+ if (!state || state.session_id !== sessionID) {
+ return null
+ }
+
+ state.message_count_at_start = messageCountAtStart
+ if (!writeState(directory, state, stateDir)) {
+ return null
+ }
+
+ return state
+ },
}
}
diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts
index 7d86d79e..bca4cb34 100644
--- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts
+++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts
@@ -84,6 +84,7 @@ export function createRalphLoopEventHandler(
promise: state.completion_promise,
apiTimeoutMs: options.apiTimeoutMs,
directory: options.directory,
+ sinceMessageIndex: state.message_count_at_start,
})
if (completionViaTranscript || completionViaApi) {
diff --git a/src/hooks/ralph-loop/ralph-loop-hook.ts b/src/hooks/ralph-loop/ralph-loop-hook.ts
index 6cb9d28d..9c069ee8 100644
--- a/src/hooks/ralph-loop/ralph-loop-hook.ts
+++ b/src/hooks/ralph-loop/ralph-loop-hook.ts
@@ -13,6 +13,7 @@ export interface RalphLoopHook {
options?: {
maxIterations?: number
completionPromise?: string
+ messageCountAtStart?: number
ultrawork?: boolean
strategy?: "reset" | "continue"
}
diff --git a/src/hooks/ralph-loop/storage.ts b/src/hooks/ralph-loop/storage.ts
index fe1e44fa..c9ef6b6c 100644
--- a/src/hooks/ralph-loop/storage.ts
+++ b/src/hooks/ralph-loop/storage.ts
@@ -44,6 +44,12 @@ export function readState(directory: string, customPath?: string): RalphLoopStat
active: isActive,
iteration: iterationNum,
max_iterations: Number(data.max_iterations) || DEFAULT_MAX_ITERATIONS,
+ message_count_at_start:
+ typeof data.message_count_at_start === "number"
+ ? data.message_count_at_start
+ : typeof data.message_count_at_start === "string" && data.message_count_at_start.trim() !== ""
+ ? Number(data.message_count_at_start)
+ : undefined,
completion_promise: stripQuotes(data.completion_promise) || DEFAULT_COMPLETION_PROMISE,
started_at: stripQuotes(data.started_at) || new Date().toISOString(),
prompt: body.trim(),
@@ -72,13 +78,17 @@ export function writeState(
const sessionIdLine = state.session_id ? `session_id: "${state.session_id}"\n` : ""
const ultraworkLine = state.ultrawork !== undefined ? `ultrawork: ${state.ultrawork}\n` : ""
const strategyLine = state.strategy ? `strategy: "${state.strategy}"\n` : ""
+ const messageCountAtStartLine =
+ typeof state.message_count_at_start === "number"
+ ? `message_count_at_start: ${state.message_count_at_start}\n`
+ : ""
const content = `---
active: ${state.active}
iteration: ${state.iteration}
max_iterations: ${state.max_iterations}
completion_promise: "${state.completion_promise}"
started_at: "${state.started_at}"
-${sessionIdLine}${ultraworkLine}${strategyLine}---
+${sessionIdLine}${ultraworkLine}${strategyLine}${messageCountAtStartLine}---
${state.prompt}
`
diff --git a/src/hooks/ralph-loop/types.ts b/src/hooks/ralph-loop/types.ts
index bdc704f3..9e32c48a 100644
--- a/src/hooks/ralph-loop/types.ts
+++ b/src/hooks/ralph-loop/types.ts
@@ -4,6 +4,7 @@ export interface RalphLoopState {
active: boolean
iteration: number
max_iterations: number
+ message_count_at_start?: number
completion_promise: string
started_at: string
prompt: string