diff --git a/apps/hook/README.md b/apps/hook/README.md
index b585a96..96c747a 100644
--- a/apps/hook/README.md
+++ b/apps/hook/README.md
@@ -52,7 +52,7 @@ If you prefer not to use the plugin system, add this to your `~/.claude/settings
{
"type": "command",
"command": "plannotator",
- "timeout": 1800
+ "timeout": 345600
}
]
}
@@ -133,4 +133,3 @@ tags: [plan, authentication, typescript, sql]
```
-
diff --git a/apps/marketing/src/content/docs/getting-started/configuration.md b/apps/marketing/src/content/docs/getting-started/configuration.md
index b8dde9c..e46d911 100644
--- a/apps/marketing/src/content/docs/getting-started/configuration.md
+++ b/apps/marketing/src/content/docs/getting-started/configuration.md
@@ -35,7 +35,7 @@ The hook is defined in `hooks.json` inside the plugin directory. When installed
{
"type": "command",
"command": "plannotator",
- "timeout": 1800
+ "timeout": 345600
}
]
}
@@ -44,7 +44,7 @@ The hook is defined in `hooks.json` inside the plugin directory. When installed
}
```
-The `matcher` targets the `ExitPlanMode` tool specifically. The `timeout` is in seconds (30 minutes) — plan reviews can take a while.
+The `matcher` targets the `ExitPlanMode` tool specifically. The `timeout` is in seconds (`345600` = 96 hours) — long reviews can stay open without expiring.
## Plugin configuration (OpenCode)
diff --git a/apps/marketing/src/content/docs/getting-started/installation.md b/apps/marketing/src/content/docs/getting-started/installation.md
index 37e5efe..080f75e 100644
--- a/apps/marketing/src/content/docs/getting-started/installation.md
+++ b/apps/marketing/src/content/docs/getting-started/installation.md
@@ -57,7 +57,7 @@ If you prefer not to use the plugin system, add this to your `~/.claude/settings
{
"type": "command",
"command": "plannotator",
- "timeout": 1800
+ "timeout": 345600
}
]
}
diff --git a/apps/marketing/src/content/docs/reference/environment-variables.md b/apps/marketing/src/content/docs/reference/environment-variables.md
index 4fc284e..98b747d 100644
--- a/apps/marketing/src/content/docs/reference/environment-variables.md
+++ b/apps/marketing/src/content/docs/reference/environment-variables.md
@@ -18,6 +18,7 @@ All Plannotator environment variables and their defaults.
| `BROWSER` | (none) | Standard env var for specifying a browser. VS Code sets this automatically in devcontainers. Used as fallback when `PLANNOTATOR_BROWSER` is not set. |
| `PLANNOTATOR_SHARE` | enabled | Set to `disabled` to turn off sharing. Hides share UI and import options. |
| `PLANNOTATOR_SHARE_URL` | `https://share.plannotator.ai` | Base URL for share links. Set this when self-hosting the share portal. |
+| `PLANNOTATOR_PLAN_TIMEOUT_SECONDS` | `345600` | OpenCode only. `submit_plan` wait timeout in seconds. Set `0` to disable timeout. |
## Paste service variables
diff --git a/apps/opencode-plugin/README.md b/apps/opencode-plugin/README.md
index 40aba0e..f85e536 100644
--- a/apps/opencode-plugin/README.md
+++ b/apps/opencode-plugin/README.md
@@ -59,6 +59,7 @@ Restart OpenCode. The `submit_plan` tool is now available.
| `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. |
| `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. |
| `PLANNOTATOR_SHARE_URL` | Custom share portal URL for self-hosting. Default: `https://share.plannotator.ai`. |
+| `PLANNOTATOR_PLAN_TIMEOUT_SECONDS` | Timeout for `submit_plan` review wait. Default: `345600` (96h). Set `0` to disable timeout. |
## Devcontainer / Docker
diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts
index e4c4bf4..0ac9a2d 100644
--- a/apps/opencode-plugin/index.ts
+++ b/apps/opencode-plugin/index.ts
@@ -8,6 +8,7 @@
* Environment variables:
* PLANNOTATOR_REMOTE - Set to "1" or "true" for remote mode (devcontainer, SSH)
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote)
+ * PLANNOTATOR_PLAN_TIMEOUT_SECONDS - Max wait for submit_plan approval (default: 345600, set 0 to disable)
*
* @packageDocumentation
*/
@@ -36,6 +37,8 @@ const htmlContent = indexHtml as unknown as string;
import reviewHtml from "./review-editor.html" with { type: "text" };
const reviewHtmlContent = reviewHtml as unknown as string;
+const DEFAULT_PLAN_TIMEOUT_SECONDS = 345_600; // 96 hours
+
export const PlannotatorPlugin: Plugin = async (ctx) => {
// Helper to determine if sharing is enabled (lazy evaluation)
// Priority: OpenCode config > env var > default (enabled)
@@ -60,6 +63,28 @@ export const PlannotatorPlugin: Plugin = async (ctx) => {
return process.env.PLANNOTATOR_SHARE_URL || undefined;
}
+ /**
+ * submit_plan wait timeout in seconds.
+ * - unset: default to 96h
+ * - 0: disable timeout
+ * - invalid/negative: fall back to default
+ */
+ function getPlanTimeoutSeconds(): number | null {
+ const raw = process.env.PLANNOTATOR_PLAN_TIMEOUT_SECONDS?.trim();
+ if (!raw) return DEFAULT_PLAN_TIMEOUT_SECONDS;
+
+ const parsed = Number.parseInt(raw, 10);
+ if (!Number.isFinite(parsed) || parsed < 0) {
+ console.error(
+ `[Plannotator] Invalid PLANNOTATOR_PLAN_TIMEOUT_SECONDS="${raw}". Using default ${DEFAULT_PLAN_TIMEOUT_SECONDS}s.`
+ );
+ return DEFAULT_PLAN_TIMEOUT_SECONDS;
+ }
+
+ if (parsed === 0) return null;
+ return parsed;
+ }
+
return {
// Register submit_plan as primary-only tool (hidden from sub-agents)
config: async (opencodeConfig) => {
@@ -321,17 +346,26 @@ Do NOT proceed with implementation until your plan is approved.
},
});
- const PLANNOTATOR_TIMEOUT_MS = 10 * 60 * 1000; // 10min timeout
- let timeoutId: ReturnType;
- const result = await Promise.race([
- server.waitForDecision().then((r) => { clearTimeout(timeoutId); return r; }),
- new Promise<{ approved: boolean; feedback?: string }>((resolve) => {
- timeoutId = setTimeout(
- () => resolve({ approved: false, feedback: "[Plannotator] No response within 10 minutes. Port released automatically. Please call submit_plan again." }),
- PLANNOTATOR_TIMEOUT_MS
- );
- }),
- ]);
+ const timeoutSeconds = getPlanTimeoutSeconds();
+ const timeoutMs = timeoutSeconds === null ? null : timeoutSeconds * 1000;
+
+ const result = timeoutMs === null
+ ? await server.waitForDecision()
+ : await new Promise>>((resolve) => {
+ const timeoutId = setTimeout(
+ () =>
+ resolve({
+ approved: false,
+ feedback: `[Plannotator] No response within ${timeoutSeconds} seconds. Port released automatically. Please call submit_plan again.`,
+ }),
+ timeoutMs
+ );
+
+ server.waitForDecision().then((r) => {
+ clearTimeout(timeoutId);
+ resolve(r);
+ });
+ });
await Bun.sleep(1500);
server.stop();