From e802a3331a86cab4eabcb7a7268c2d844be5082e Mon Sep 17 00:00:00 2001 From: OpenCode Runner Date: Tue, 31 Mar 2026 21:19:39 +0000 Subject: [PATCH] Fix: Add sandbox-awareness recovery nudge after repeated tool denials (#5) When the agent repeatedly hits permission denials or tool errors, it now receives a nudge after 2 consecutive failures to guide it toward finding an alternative approach within the allowed workspace. Changes: - Track permission denials via permission.replied event - Track tool errors via tool.execute.after hook (permission-related errors) - Reset deny counter when session status transitions to busy - Configurable threshold via OPENCODE_DENY_THRESHOLD env var (default: 2) - Added unit tests covering all acceptance criteria - Import getOrCreateState from throttle.ts (avoid duplication) Fixes #5 --- issue-5-comment-4165628200-results.txt | 1 + opencode-nudge/package-lock.json | 311 ++++++++++++++++++++++++ opencode-nudge/package.json | 1 + opencode-nudge/src/deny-handler.test.ts | 163 +++++++++++++ opencode-nudge/src/deny-handler.ts | 78 ++++++ opencode-nudge/src/index.ts | 14 +- opencode-nudge/src/throttle.ts | 2 + opencode-nudge/src/types.ts | 10 + 8 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 issue-5-comment-4165628200-results.txt create mode 100644 opencode-nudge/package-lock.json create mode 100644 opencode-nudge/src/deny-handler.test.ts create mode 100644 opencode-nudge/src/deny-handler.ts diff --git a/issue-5-comment-4165628200-results.txt b/issue-5-comment-4165628200-results.txt new file mode 100644 index 0000000..5b8e800 --- /dev/null +++ b/issue-5-comment-4165628200-results.txt @@ -0,0 +1 @@ +Now I understand the codebase. Let me implement the sandbox-awareness feature. diff --git a/opencode-nudge/package-lock.json b/opencode-nudge/package-lock.json new file mode 100644 index 0000000..3e82773 --- /dev/null +++ b/opencode-nudge/package-lock.json @@ -0,0 +1,311 @@ +{ + "name": "opencode-nudge", + "version": "0.2.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opencode-nudge", + "version": "0.2.3", + "license": "MIT", + "devDependencies": { + "@opencode-ai/plugin": "1.3.3", + "@opencode-ai/sdk": "^1.3.6", + "@tsconfig/node22": "^22.0.0", + "@types/node": "^22.0.0", + "bun": "^1.3.11", + "bun-types": "^1.3.11", + "typescript": "^5.8.2" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.3.0" + } + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.3.tgz", + "integrity": "sha512-pxI4LanjnQb8sUd/zfQilzlGHyrdjmZuQ1XsUFbm+rij4yq0mUPtXcPGfuZJBEcGchKn57tA3/LB6RmipLQpXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.3.3", + "zod": "4.1.8" + } + }, + "node_modules/@opencode-ai/plugin/node_modules/@opencode-ai/sdk": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.3.tgz", + "integrity": "sha512-qg7DwEVUpZArsYajs0DcaHqmIYB3EfHCuuTdMJir7Yc976DUWDfLR/5y9h8fKb9HAMJXe4TZkAIEr0/3OmK67g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.11.tgz", + "integrity": "sha512-ys9Ezv/7dGYnqr+nVkDFjSLATy+Itg9RYBdJgFXqsPEp852oCMhJEfGeZZ3Plhyhx3kOYa6iPNAgVR6xuCKKeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oven/bun-darwin-aarch64": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.11.tgz", + "integrity": "sha512-/8IzqSu4/OWGRs7Fs2ROzGVwJMFTBQkgAp6sAthkBYoN7OiM4rY/CpPVs2X9w9N1W61CHSkEdNKi8HrLZKfK3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.11.tgz", + "integrity": "sha512-TT7eUihnAzxM2tlZesusuC75PAOYKvUBgVU/Nm/lakZ/DpyuqhNkzUfcxSgmmK9IjVWzMmezLIGZl16XGCGJng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64-baseline": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.11.tgz", + "integrity": "sha512-CYjIHWaQG7T4phfjErHr6BiXRs0K/9DqMeiohJmuYSBF+H2m56vFslOenLCguGYQL9jeiiCZBeoVCpwjxZrMgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-linux-aarch64": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.11.tgz", + "integrity": "sha512-8XMLyRNxHF4jfLajkWt+F8UDxsWbzysyxQVMZKUXwoeGvaxB0rVd07r3YbgDtG8U6khhRFM3oaGp+CQ0whwmdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-aarch64-musl": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.11.tgz", + "integrity": "sha512-jBwYCLG5Eb+PqtFrc3Wp2WMYlw1Id75gUcsdP+ApCOpf5oQhHxkFWCjZmcDoioDmEhMWAiM3wtwSrTlPg+sI6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.11.tgz", + "integrity": "sha512-z3GFCk1UBzDOOiEBHL32lVP7Edi26BhOjKb6bIc0nRyabbRiyON4++GR0zmd/H5zM5S0+UcXFgCGnD+b8avTLw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-baseline": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.11.tgz", + "integrity": "sha512-KZlf1jKtf4jai8xiQv/0XRjxVVhHnw/HtUKtLdOeQpTOQ1fQFhLoz2FGGtVRd0LVa/yiRbSz9HlWIzWlmJClng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.11.tgz", + "integrity": "sha512-ADImD4yCHNpqZu718E2chWcCaAHvua90yhmpzzV6fF4zOhwkGGbPCgUWmKyJ83uz+DXaPdYxX0ttDvtolrzx3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl-baseline": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.11.tgz", + "integrity": "sha512-J+qz4Al05PrNIOdj7xsWVTyx0c/gjUauG5nKV3Rrx0Q+5JO+1pPVlnfNmWbOF9pKG4f3IGad8KXJUfGMORld+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-windows-aarch64": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.11.tgz", + "integrity": "sha512-UOdkwScHRkGPz+n9ZJU7sTkTvqV7rD1SLCLaru1xH8WRsV7tDorPqNCzEN1msOIiPRK825nvAtEm9UsomO1GsA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oven/bun-windows-x64": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.11.tgz", + "integrity": "sha512-E51tyWDP1l0CbjZYhiUxhDGPaY8Hf5YBREx0PHBff1LM1/q3qsJ6ZvRUa8YbbOO0Ax9QP6GHjD9vf3n6bXZ7QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oven/bun-windows-x64-baseline": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.11.tgz", + "integrity": "sha512-cCsXK9AQ9Zf18QlVnbrFu2IKfr4sf2sfbErkF2jfCzyCO9Bnhl0KRx63zlN+Ni1xU7gcBLAssgcui5R400N2eA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tsconfig/node22": { + "version": "22.0.5", + "resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.5.tgz", + "integrity": "sha512-hLf2ld+sYN/BtOJjHUWOk568dvjFQkHnLNa6zce25GIH+vxKfvTgm3qpaH6ToF5tu/NN0IH66s+Bb5wElHrLcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/bun": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.11.tgz", + "integrity": "sha512-AvXWYFO6j/ZQ7bhGm4X6eilq2JHsDVC90ZM32k2B7/srhC2gs3Sdki1QTbwrdRCo8o7eT+167vcB1yzOvPdbjA==", + "cpu": [ + "arm64", + "x64" + ], + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "bin": { + "bun": "bin/bun.exe", + "bunx": "bin/bunx.exe" + }, + "optionalDependencies": { + "@oven/bun-darwin-aarch64": "1.3.11", + "@oven/bun-darwin-x64": "1.3.11", + "@oven/bun-darwin-x64-baseline": "1.3.11", + "@oven/bun-linux-aarch64": "1.3.11", + "@oven/bun-linux-aarch64-musl": "1.3.11", + "@oven/bun-linux-x64": "1.3.11", + "@oven/bun-linux-x64-baseline": "1.3.11", + "@oven/bun-linux-x64-musl": "1.3.11", + "@oven/bun-linux-x64-musl-baseline": "1.3.11", + "@oven/bun-windows-aarch64": "1.3.11", + "@oven/bun-windows-x64": "1.3.11", + "@oven/bun-windows-x64-baseline": "1.3.11" + } + }, + "node_modules/bun-types": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.11.tgz", + "integrity": "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/opencode-nudge/package.json b/opencode-nudge/package.json index 304d9e4..d82e6e2 100644 --- a/opencode-nudge/package.json +++ b/opencode-nudge/package.json @@ -36,6 +36,7 @@ "@opencode-ai/sdk": "^1.3.6", "@tsconfig/node22": "^22.0.0", "@types/node": "^22.0.0", + "bun": "^1.3.11", "bun-types": "^1.3.11", "typescript": "^5.8.2" } diff --git a/opencode-nudge/src/deny-handler.test.ts b/opencode-nudge/src/deny-handler.test.ts new file mode 100644 index 0000000..1080463 --- /dev/null +++ b/opencode-nudge/src/deny-handler.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test" +import { handlePermissionReplied, handleToolError, handleSessionStatus } from "./deny-handler.js" +import { sessionStates, getDenyThreshold, SANDBOX_PROMPT } from "./types.js" + +function makeClient(promptFn = mock(() => Promise.resolve())) { + return { + app: { log: mock(() => undefined) }, + session: { promptAsync: promptFn }, + } as any +} + +let savedDenyEnv: string | undefined +function isolateDenyEnv() { + beforeEach(() => { + savedDenyEnv = process.env["OPENCODE_DENY_THRESHOLD"] + delete process.env["OPENCODE_DENY_THRESHOLD"] + }) + afterEach(() => { + if (savedDenyEnv !== undefined) { + process.env["OPENCODE_DENY_THRESHOLD"] = savedDenyEnv + } else { + delete process.env["OPENCODE_DENY_THRESHOLD"] + } + }) +} + +describe("handlePermissionReplied", () => { + isolateDenyEnv() + beforeEach(() => sessionStates.clear()) + + it("does not nudge on single denial", () => { + const client = makeClient() + handlePermissionReplied({ sessionID: "s1" }, client as any) + expect(client.session.promptAsync).not.toHaveBeenCalled() + expect(sessionStates.get("s1")!.denyCount).toBe(1) + }) + + it("nudges when threshold (2) is reached", async () => { + const promptFn = mock(() => Promise.resolve()) + const client = makeClient(promptFn) + handlePermissionReplied({ sessionID: "s2" }, client as any) + expect(client.session.promptAsync).not.toHaveBeenCalled() + handlePermissionReplied({ sessionID: "s2" }, client as any) + expect(promptFn).toHaveBeenCalledTimes(1) + const calls = promptFn.mock.calls as unknown as Array<[{ path: { id: string }; body: { parts: Array<{ text: string }> } }]> + const call = calls[0]![0]! + expect(call.path.id).toBe("s2") + expect(call.body.parts[0]!.text).toBe(SANDBOX_PROMPT) + }) + + it("resets denyCount after successful nudge", () => { + const client = makeClient(mock(() => Promise.resolve())) + handlePermissionReplied({ sessionID: "s3" }, client as any) + handlePermissionReplied({ sessionID: "s3" }, client as any) + expect(sessionStates.get("s3")!.denyCount).toBe(0) + }) + + it("does not throw when promptAsync rejects", () => { + const client = makeClient(mock(() => Promise.reject(new Error("network error")))) + handlePermissionReplied({ sessionID: "s4" }, client as any) + handlePermissionReplied({ sessionID: "s4" }, client as any) + expect(sessionStates.get("s4")!.denyCount).toBe(0) + }) +}) + +describe("handleToolError", () => { + isolateDenyEnv() + beforeEach(() => sessionStates.clear()) + + it("does not trigger on non-permission errors", () => { + const client = makeClient() + handleToolError({ sessionID: "t1" }, { output: "file not found" }, client as any) + expect(client.session.promptAsync).not.toHaveBeenCalled() + }) + + it("matches 'permission denied' pattern", () => { + const client = makeClient() + handleToolError({ sessionID: "t2" }, { output: "Error: permission denied" }, client as any) + expect(sessionStates.get("t2")!.denyCount).toBe(1) + }) + + it("matches 'EACCES' pattern", () => { + const client = makeClient() + handleToolError({ sessionID: "t3" }, { output: "EACCES: permission denied" }, client as any) + expect(sessionStates.get("t3")!.denyCount).toBe(1) + }) + + it("matches 'Operation not permitted' pattern", () => { + const client = makeClient() + handleToolError({ sessionID: "t4" }, { output: "Operation not permitted" }, client as any) + expect(sessionStates.get("t4")!.denyCount).toBe(1) + }) + + it("matches 'not allowed' pattern (case insensitive)", () => { + const client = makeClient() + handleToolError({ sessionID: "t5" }, { output: "Access NOT ALLOWED" }, client as any) + expect(sessionStates.get("t5")!.denyCount).toBe(1) + }) + + it("triggers nudge after threshold with tool errors", async () => { + const promptFn = mock(() => Promise.resolve()) + const client = makeClient(promptFn) + handleToolError({ sessionID: "t6" }, { output: "permission denied" }, client as any) + expect(promptFn).not.toHaveBeenCalled() + handleToolError({ sessionID: "t6" }, { output: "EACCES: access denied" }, client as any) + expect(promptFn).toHaveBeenCalledTimes(1) + }) +}) + +describe("handleSessionStatus", () => { + isolateDenyEnv() + beforeEach(() => sessionStates.clear()) + + it("resets denyCount when session goes to busy (active)", () => { + const client = makeClient() + handlePermissionReplied({ sessionID: "r1" }, client as any) + handlePermissionReplied({ sessionID: "r1" }, client as any) + expect(sessionStates.get("r1")!.denyCount).toBe(0) + handlePermissionReplied({ sessionID: "r1" }, client as any) + expect(sessionStates.get("r1")!.denyCount).toBe(1) + handleSessionStatus( + { event: { type: "session.status", properties: { sessionID: "r1", status: { type: "busy" } } } as any }, + client as any + ) + expect(sessionStates.get("r1")!.denyCount).toBe(0) + }) + + it("does nothing for idle status", () => { + const client = makeClient() + handlePermissionReplied({ sessionID: "r2" }, client as any) + handleSessionStatus( + { event: { type: "session.status", properties: { sessionID: "r2", status: { type: "idle" } } } as any }, + client as any + ) + expect(sessionStates.get("r2")!.denyCount).toBe(1) + }) + + it("does nothing for retry status", () => { + const client = makeClient() + handlePermissionReplied({ sessionID: "r3" }, client as any) + handleSessionStatus( + { event: { type: "session.status", properties: { sessionID: "r3", status: { type: "retry", attempt: 1, message: "error", next: 1000 } } } as any }, + client as any + ) + expect(sessionStates.get("r3")!.denyCount).toBe(1) + }) +}) + +describe("getDenyThreshold", () => { + afterEach(() => { + delete process.env["OPENCODE_DENY_THRESHOLD"] + }) + + it("defaults to 2", () => { + delete process.env["OPENCODE_DENY_THRESHOLD"] + expect(getDenyThreshold()).toBe(2) + }) + + it("reads from env var", () => { + process.env["OPENCODE_DENY_THRESHOLD"] = "3" + expect(getDenyThreshold()).toBe(3) + }) +}) diff --git a/opencode-nudge/src/deny-handler.ts b/opencode-nudge/src/deny-handler.ts new file mode 100644 index 0000000..313edf1 --- /dev/null +++ b/opencode-nudge/src/deny-handler.ts @@ -0,0 +1,78 @@ +import type { Event } from "@opencode-ai/sdk" +import type { PluginInput } from "@opencode-ai/plugin" +import type { SessionState } from "./types.js" +import { + getDenyThreshold, + DENY_COOLDOWN, + SANDBOX_PROMPT, + sessionStates, +} from "./types.js" +import { getOrCreateState } from "./throttle.js" + +type Client = PluginInput["client"] + +const PERMISSION_ERROR_PATTERN = /permission denied|EACCES|Operation not permitted|not allowed/i + +function log(client: Client, level: "debug" | "info" | "warn" | "error", message: string, extra?: Record): void { + client.app.log({ body: { service: "opencode-nudge", level, message, extra } }) +} + +export function handlePermissionReplied( + { sessionID }: { sessionID: string }, + client: Client +): void { + const state = getOrCreateState(sessionID) + state.denyCount++ + log(client, "debug", "permission denied", { sessionID, denyCount: state.denyCount }) + maybeInjectDenyNudge(sessionID, state, client) +} + +export function handleToolError( + { sessionID }: { sessionID: string }, + output: { output: string }, + client: Client +): void { + if (!PERMISSION_ERROR_PATTERN.test(output.output)) return + const state = getOrCreateState(sessionID) + state.denyCount++ + log(client, "debug", "tool error detected (permission-related)", { sessionID, denyCount: state.denyCount }) + maybeInjectDenyNudge(sessionID, state, client) +} + +export function handleSessionStatus( + { event }: { event: Event }, + client: Client +): void { + if (event.type !== "session.status") return + if (event.properties.status.type === "busy") { + const sessionID = event.properties.sessionID + const state = sessionStates.get(sessionID) + if (state) { + state.denyCount = 0 + log(client, "debug", "session busy, reset deny count", { sessionID }) + } + } +} + +function maybeInjectDenyNudge(sessionID: string, state: SessionState, client: Client): void { + const threshold = getDenyThreshold() + if (state.denyCount < threshold) return + + const now = Date.now() + if (state.lastDenyNudge > 0 && now - state.lastDenyNudge < DENY_COOLDOWN) { + log(client, "debug", "deny threshold reached but throttled", { sessionID }) + return + } + + state.lastDenyNudge = now + state.denyCount = 0 + + client.session.promptAsync({ + path: { id: sessionID }, + body: { parts: [{ type: "text", text: SANDBOX_PROMPT }] }, + }).then(() => { + log(client, "info", "sandbox-awareness nudge injected", { sessionID }) + }).catch((err) => { + log(client, "error", "failed to inject sandbox-awareness nudge", { sessionID, err: String(err) }) + }) +} diff --git a/opencode-nudge/src/index.ts b/opencode-nudge/src/index.ts index 78014b5..c442a20 100644 --- a/opencode-nudge/src/index.ts +++ b/opencode-nudge/src/index.ts @@ -1,14 +1,26 @@ import type { Plugin } from "@opencode-ai/plugin" import { handleIdleEvent, handleUserMessage } from "./idle-handler.js" +import { handlePermissionReplied, handleToolError, handleSessionStatus } from "./deny-handler.js" export const OpencodeNudgePlugin: Plugin = async (input) => { input.client.app.log({ body: { service: "opencode-nudge", level: "info", message: "plugin loaded" } }) return { - event: ({ event }) => handleIdleEvent({ event }, input.client), + event: ({ event }) => { + handleIdleEvent({ event }, input.client) + handleSessionStatus({ event }, input.client) + if (event.type === "permission.replied" && event.properties.response === "deny") { + handlePermissionReplied({ sessionID: event.properties.sessionID }, input.client) + } + return Promise.resolve() + }, "chat.message": (messageInput) => { handleUserMessage({ sessionID: messageInput.sessionID }) return Promise.resolve() }, + "tool.execute.after": (_input, output) => { + handleToolError(_input, output, input.client) + return Promise.resolve() + }, } } diff --git a/opencode-nudge/src/throttle.ts b/opencode-nudge/src/throttle.ts index 9991839..9dbc5a1 100644 --- a/opencode-nudge/src/throttle.ts +++ b/opencode-nudge/src/throttle.ts @@ -14,6 +14,8 @@ export function getOrCreateState(sessionID: string): SessionState { hourStart: 0, lastIdleSeen: 0, lastUserMessage: 0, + denyCount: 0, + lastDenyNudge: 0, }) } return sessionStates.get(sessionID)! diff --git a/opencode-nudge/src/types.ts b/opencode-nudge/src/types.ts index b2d8d9a..6fe0fba 100644 --- a/opencode-nudge/src/types.ts +++ b/opencode-nudge/src/types.ts @@ -4,16 +4,26 @@ export interface SessionState { hourStart: number // ms timestamp marking start of current hour window lastIdleSeen: number // ms timestamp when session.idle was first observed (0 = not idle) lastUserMessage: number // ms timestamp of last incoming user message (0 = never) + denyCount: number // consecutive permission denials/errors in this session + lastDenyNudge: number // ms timestamp of last deny-nudge (0 = never) } export function getIdleThreshold(): number { const env = process.env["OPENCODE_IDLE_THRESHOLD_MS"] return env !== undefined ? parseInt(env, 10) : 5 * 60 * 1000 // 300,000ms default } + +export function getDenyThreshold(): number { + const env = process.env["OPENCODE_DENY_THRESHOLD"] + return env !== undefined ? parseInt(env, 10) : 2 // default 2 consecutive failures +} export const COOLDOWN_PERIOD = 10 * 60 * 1000 // 600,000ms — minimum gap between two nudges export const MAX_HOURLY_CONTINUES = 3 // hard cap on nudges per hour export const ONE_HOUR = 60 * 60 * 1000 // 3,600,000ms — rolling hour window size export const CONTINUE_PROMPT = "Only continue if you were clearly interrupted mid-task (e.g. a tool call, loop, or step sequence was cut short). If so, resume — and consider a more interruption-resistant approach. Do NOT invent next steps or start new work just because it seems logical." +export const DENY_COOLDOWN = 5 * 60 * 1000 // 300,000ms — minimum gap between deny nudges +export const SANDBOX_PROMPT = + "Several tool calls have been denied or failed. You may be running in a sandboxed environment where file access is restricted to the project working directory. Avoid retrying the same path — find an alternative approach within the allowed workspace." export const sessionStates = new Map()