diff --git a/src/hooks/gpt-permission-continuation/constants.ts b/src/hooks/gpt-permission-continuation/constants.ts
index 690a0f0bd..04eda9a72 100644
--- a/src/hooks/gpt-permission-continuation/constants.ts
+++ b/src/hooks/gpt-permission-continuation/constants.ts
@@ -1,5 +1,6 @@
export const HOOK_NAME = "gpt-permission-continuation"
export const CONTINUATION_PROMPT = "continue"
+export const MAX_CONSECUTIVE_AUTO_CONTINUES = 3
export const DEFAULT_STALL_PATTERNS = [
"if you want",
diff --git a/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts b/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts
index dfb8b4591..c4068566f 100644
--- a/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts
+++ b/src/hooks/gpt-permission-continuation/gpt-permission-continuation.test.ts
@@ -1,4 +1,6 @@
-import { describe, expect, test } from "bun:test"
+///
+
+import { describe, expect, it as test } from "bun:test"
import { createGptPermissionContinuationHook } from "."
@@ -38,6 +40,20 @@ function createMockPluginInput(messages: SessionMessage[]) {
return { ctx, promptCalls }
}
+function createAssistantMessage(id: string, text: string): SessionMessage {
+ return {
+ info: { id, role: "assistant", modelID: "gpt-5.4" },
+ parts: [{ type: "text", text }],
+ }
+}
+
+function createUserMessage(id: string, text: string): SessionMessage {
+ return {
+ info: { id, role: "user" },
+ parts: [{ type: "text", text }],
+ }
+}
+
describe("gpt-permission-continuation", () => {
test("injects continue when the last GPT assistant reply asks for permission", async () => {
// given
@@ -147,4 +163,87 @@ describe("gpt-permission-continuation", () => {
// then
expect(promptCalls).toEqual(["continue"])
})
+
+ describe("#given repeated GPT permission tails in the same session", () => {
+ describe("#when the permission phrases keep changing", () => {
+ test("stops injecting after three consecutive auto-continues", async () => {
+ // given
+ const messages: SessionMessage[] = [
+ createUserMessage("msg-0", "Please continue the fix."),
+ createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
+ ]
+ const { ctx, promptCalls } = createMockPluginInput(messages)
+ const hook = createGptPermissionContinuationHook(ctx)
+
+ // when
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
+ messages.push(createUserMessage("msg-2", "continue"))
+ messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?"))
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
+ messages.push(createUserMessage("msg-4", "continue"))
+ messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?"))
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
+ messages.push(createUserMessage("msg-6", "continue"))
+ messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?"))
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
+
+ // then
+ expect(promptCalls).toEqual(["continue", "continue", "continue"])
+ })
+ })
+
+ describe("#when a real user message arrives between auto-continues", () => {
+ test("resets the consecutive auto-continue counter", async () => {
+ // given
+ const messages: SessionMessage[] = [
+ createUserMessage("msg-0", "Please continue the fix."),
+ createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
+ ]
+ const { ctx, promptCalls } = createMockPluginInput(messages)
+ const hook = createGptPermissionContinuationHook(ctx)
+
+ // when
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
+ messages.push(createUserMessage("msg-2", "continue"))
+ messages.push(createAssistantMessage("msg-3", "Would you like me to continue with the tests?"))
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
+ messages.push(createUserMessage("msg-4", "Please keep going and finish the cleanup."))
+ messages.push(createAssistantMessage("msg-5", "Do you want me to wire the remaining cleanup?"))
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
+ messages.push(createUserMessage("msg-6", "continue"))
+ messages.push(createAssistantMessage("msg-7", "Shall I finish the remaining updates?"))
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
+ messages.push(createUserMessage("msg-8", "continue"))
+ messages.push(createAssistantMessage("msg-9", "If you want, I can apply the final polish."))
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
+ messages.push(createUserMessage("msg-10", "continue"))
+ messages.push(createAssistantMessage("msg-11", "Would you like me to ship the final verification?"))
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
+
+ // then
+ expect(promptCalls).toEqual(["continue", "continue", "continue", "continue", "continue"])
+ })
+ })
+
+ describe("#when the same permission phrase repeats after an auto-continue", () => {
+ test("stops immediately on stagnation", async () => {
+ // given
+ const messages: SessionMessage[] = [
+ createUserMessage("msg-0", "Please continue the fix."),
+ createAssistantMessage("msg-1", "If you want, I can apply the patch next."),
+ ]
+ const { ctx, promptCalls } = createMockPluginInput(messages)
+ const hook = createGptPermissionContinuationHook(ctx)
+
+ // when
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
+ messages.push(createUserMessage("msg-2", "continue"))
+ messages.push(createAssistantMessage("msg-3", "If you want, I can apply the patch next."))
+ await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
+
+ // then
+ expect(promptCalls).toEqual(["continue"])
+ })
+ })
+ })
})
diff --git a/src/hooks/gpt-permission-continuation/handler.ts b/src/hooks/gpt-permission-continuation/handler.ts
index 0db3142dc..252f8e357 100644
--- a/src/hooks/gpt-permission-continuation/handler.ts
+++ b/src/hooks/gpt-permission-continuation/handler.ts
@@ -9,10 +9,16 @@ import {
isGptAssistantMessage,
type SessionMessage,
} from "./assistant-message"
-import { CONTINUATION_PROMPT, HOOK_NAME } from "./constants"
+import {
+ CONTINUATION_PROMPT,
+ HOOK_NAME,
+ MAX_CONSECUTIVE_AUTO_CONTINUES,
+} from "./constants"
import { detectStallPattern } from "./detector"
import type { SessionStateStore } from "./session-state"
+type SessionState = ReturnType
+
async function promptContinuation(
ctx: PluginInput,
sessionID: string,
@@ -33,6 +39,37 @@ async function promptContinuation(
await ctx.client.session.prompt(payload)
}
+function getLastUserMessageBefore(
+ messages: SessionMessage[],
+ lastAssistantIndex: number,
+): SessionMessage | null {
+ for (let index = lastAssistantIndex - 1; index >= 0; index--) {
+ if (messages[index].info?.role === "user") {
+ return messages[index]
+ }
+ }
+
+ return null
+}
+
+function isAutoContinuationUserMessage(message: SessionMessage): boolean {
+ return extractAssistantText(message).trim().toLowerCase() === CONTINUATION_PROMPT
+}
+
+function extractPermissionPhrase(text: string): string | null {
+ const tail = text.slice(-800)
+ const lines = tail.split("\n").map((line) => line.trim()).filter(Boolean)
+ const hotZone = lines.slice(-3).join(" ")
+ const sentenceParts = hotZone.trim().replace(/\s+/g, " ").split(/(?<=[.!?])\s+/)
+ const trailingSegment = sentenceParts[sentenceParts.length - 1]?.trim().toLowerCase() ?? ""
+ return trailingSegment || null
+}
+
+function resetAutoContinuationState(state: SessionState): void {
+ state.consecutiveAutoContinueCount = 0
+ state.lastAutoContinuePermissionPhrase = undefined
+}
+
export function createGptPermissionContinuationHandler(args: {
ctx: PluginInput
sessionStateStore: SessionStateStore
@@ -78,6 +115,12 @@ export function createGptPermissionContinuationHandler(args: {
const lastAssistantMessage = getLastAssistantMessage(messages)
if (!lastAssistantMessage) return
+ const lastAssistantIndex = messages.lastIndexOf(lastAssistantMessage)
+ const previousUserMessage = getLastUserMessageBefore(messages, lastAssistantIndex)
+ if (previousUserMessage && !isAutoContinuationUserMessage(previousUserMessage)) {
+ resetAutoContinuationState(state)
+ }
+
const messageID = lastAssistantMessage.info?.id
if (messageID && state.lastHandledMessageID === messageID) {
log(`[${HOOK_NAME}] Skipped: already handled assistant message`, { sessionID, messageID })
@@ -99,9 +142,39 @@ export function createGptPermissionContinuationHandler(args: {
return
}
+ const permissionPhrase = extractPermissionPhrase(assistantText)
+ if (!permissionPhrase) {
+ return
+ }
+
+ if (state.consecutiveAutoContinueCount >= MAX_CONSECUTIVE_AUTO_CONTINUES) {
+ state.lastHandledMessageID = messageID
+ log(`[${HOOK_NAME}] Skipped: reached max consecutive auto-continues`, {
+ sessionID,
+ messageID,
+ consecutiveAutoContinueCount: state.consecutiveAutoContinueCount,
+ })
+ return
+ }
+
+ if (
+ state.consecutiveAutoContinueCount >= 1
+ && state.lastAutoContinuePermissionPhrase === permissionPhrase
+ ) {
+ state.lastHandledMessageID = messageID
+ log(`[${HOOK_NAME}] Skipped: repeated permission phrase after auto-continue`, {
+ sessionID,
+ messageID,
+ permissionPhrase,
+ })
+ return
+ }
+
state.inFlight = true
await promptContinuation(ctx, sessionID)
state.lastHandledMessageID = messageID
+ state.consecutiveAutoContinueCount += 1
+ state.lastAutoContinuePermissionPhrase = permissionPhrase
state.lastInjectedAt = Date.now()
log(`[${HOOK_NAME}] Injected continuation prompt`, { sessionID, messageID })
} catch (error) {
diff --git a/src/hooks/gpt-permission-continuation/session-state.ts b/src/hooks/gpt-permission-continuation/session-state.ts
index 132675086..fc3497235 100644
--- a/src/hooks/gpt-permission-continuation/session-state.ts
+++ b/src/hooks/gpt-permission-continuation/session-state.ts
@@ -1,6 +1,8 @@
type SessionState = {
inFlight: boolean
+ consecutiveAutoContinueCount: number
lastHandledMessageID?: string
+ lastAutoContinuePermissionPhrase?: string
lastInjectedAt?: number
}
@@ -15,6 +17,7 @@ export function createSessionStateStore() {
const created: SessionState = {
inFlight: false,
+ consecutiveAutoContinueCount: 0,
}
states.set(sessionID, created)
return created