Compare commits

..

22 Commits

Author SHA1 Message Date
github-actions[bot]
ffa2a255d9 release: v3.8.3 2026-02-22 06:46:51 +00:00
YeonGyu-Kim
07e8a7c570 feat(write-existing-file-guard): allow writes outside session directory
Remove blocking logic that prevented writes to files outside the
session directory. The guard now only applies to files within the
session directory, allowing free writes to external paths.

- Remove OUTSIDE_SESSION_MESSAGE constant
- Update test to expect outside writes to be allowed
- Add early return for paths outside session directory
- Keep isPathInsideDirectory for session boundary check

TDD cycle:
1. RED: Update test expectation
2. GREEN: Implement early return for outside paths
3. REFACTOR: Clean up unused constants
2026-02-22 15:43:19 +09:00
github-actions[bot]
d0b18787ba release: v3.8.2 2026-02-22 06:35:05 +00:00
YeonGyu-Kim
4d7b98d9f2 bun 2026-02-22 15:30:59 +09:00
YeonGyu-Kim
a3e4f904a6 refactor(background-agent): wire session-idle-event-handler into manager, add unit tests
The extracted handleSessionIdleBackgroundEvent was never imported by
manager.ts — dead code from incomplete refactoring (d53bcfbc). Replace
the inline session.idle handler (58 LOC) with a call to the extracted
function, remove unused MIN_IDLE_TIME_MS import, and add 13 unit tests
covering all edge cases.
2026-02-22 15:30:40 +09:00
YeonGyu-Kim
c0636e5b0c feat(agents,hooks): wire Sisyphus Gemini overlays and add Gemini verification reminder
Sisyphus: inject TOOL_CALL_MANDATE after intent gate, append delegation
and verification override sections for Gemini models.

Atlas hook: add VERIFICATION_REMINDER_GEMINI with stronger language -
'EXTREMELY SUSPICIOUS', explicit 'NOT reasoning, TOOL CALLS', and
consequence-driven framing for Gemini's optimistic tendencies.
2026-02-22 15:30:40 +09:00
YeonGyu-Kim
49e885d81d feat(agents): wire Gemini prompt routing into Sisyphus-Junior, Atlas, Prometheus
Add 'gemini' to prompt source types and route Gemini models to new
Gemini-optimized prompts via isGeminiModel detection. Update barrel
exports for all 3 agent modules. All existing tests pass.
2026-02-22 15:30:40 +09:00
YeonGyu-Kim
bf33e6f651 feat(agents): add isGeminiModel detection function with TDD
Detects Gemini models via:
- Provider prefixes: google/, google-vertex/
- GitHub Copilot: github-copilot/gemini-*
- Model name: gemini-* (for proxied providers like litellm)

Follows existing isGptModel pattern. All 16 tests pass.
2026-02-22 15:30:40 +09:00
YeonGyu-Kim
da13a2f673 feat(agents): add Gemini-optimized prompts for Sisyphus, Sisyphus-Junior, Prometheus, Atlas
Gemini models are aggressively optimistic and avoid tool calls in favor of
internal reasoning. These prompts counter that with:
- TOOL_CALL_MANDATE sections forcing actual tool usage
- Anti-optimism checkpoints before claiming completion
- Stronger delegation enforcement (Gemini prefers doing work itself)
- Aggressive verification language (subagent results are 'EXTREMELY SUSPICIOUS')
- Mandatory thinking checkpoints in Prometheus (prevents jumping to conclusions)
- Scope discipline reminders (creativity → implementation quality, not scope creep)
2026-02-22 15:30:40 +09:00
YeonGyu-Kim
02aff32b0c Merge pull request #2039 from code-yeongyu/fix/grep-formatter-files-mode
fix(grep): format files_with_matches output as clean file paths
2026-02-22 15:26:09 +09:00
YeonGyu-Kim
c806a35e49 fix(grep): format files_with_matches output as clean file paths 2026-02-22 15:19:26 +09:00
YeonGyu-Kim
b175c11b35 Merge pull request #2009 from JiHongKim98/fix/ripgrep-cpu-throttle
fix(tools): throttle ripgrep CPU usage with thread limits and concurrency control
2026-02-22 15:09:26 +09:00
YeonGyu-Kim
7b55cbab94 Merge pull request #2030 from acamq/feature/agent-input-notifications
feat(notification): alert when agent asks questions or needs permission
2026-02-22 15:09:24 +09:00
YeonGyu-Kim
6904cba061 Merge pull request #2029 from coleleavitt/fix/plug-resource-leaks
fix: plug resource leaks and add hook command timeout
2026-02-22 15:07:02 +09:00
Cole Leavitt
116f17ed11 fix: add proc.kill fallback when process group kill fails 2026-02-21 16:45:18 -07:00
Cole Leavitt
a31109bb07 fix: kill process group on timeout and handle stdin EPIPE
- Use detached process group (non-Windows) + process.kill(-pid) to kill
  the entire process tree, not just the outer shell wrapper
- Add proc.stdin error listener to absorb EPIPE when child exits before
  stdin write completes
2026-02-21 16:45:00 -07:00
Cole Leavitt
91530234ec fix: handle signal-killed exit code and guard SIGTERM kill
- code ?? 0 → code ?? 1: signal-terminated processes return null exit code,
  which was incorrectly coerced to 0 (success) instead of 1 (failure)
- wrap proc.kill(SIGTERM) in try/catch to match SIGKILL guard and prevent
  EPERM/ESRCH from crashing on already-dead processes
2026-02-21 16:45:00 -07:00
Cole Leavitt
6aa1e96f9e fix: plug resource leaks and add hook command timeout
- LSP signal handlers: store refs, return unregister handle, call in stopAll()
- session-tools-store: add per-session deleteSessionTools(), wire into session.deleted
- executeHookCommand: add 30s timeout with SIGTERM→SIGKILL escalation
2026-02-21 16:44:59 -07:00
acamq
f265e37cbc fix(notification): use permission.asked and main-session fallback 2026-02-21 16:42:23 -07:00
acamq
931c0cd101 feat(notification): alert when agent asks questions or needs permission 2026-02-21 16:01:38 -07:00
JiHongKim98
02017a1b70 fix(tools): address PR review feedback from cubic
- Use tool.schema.enum() for output_mode instead of generic string()
- Remove unsafe type assertion for output_mode
- Fix files_with_matches mode returning empty results by adding
  filesOnly flag to parseOutput for --files-with-matches rg output
2026-02-21 03:17:48 +09:00
JiHongKim98
dafdca217b fix(tools): throttle ripgrep CPU usage with thread limits and concurrency control
- Add --threads=4 flag to all rg invocations (grep and glob)
- Add global semaphore limiting concurrent rg processes to 2
- Reduce grep timeout from 300s to 60s (matches tool description)
- Reduce max output from 10MB to 256KB (prevents excessive memory usage)
- Add output_mode parameter (content/files_with_matches/count)
- Add head_limit parameter for incremental result fetching

Closes #2008

Ref: #674, #1722
2026-02-21 03:02:01 +09:00
43 changed files with 1589 additions and 543 deletions

View File

@@ -28,13 +28,13 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.7.4",
"oh-my-opencode-darwin-x64": "3.7.4",
"oh-my-opencode-linux-arm64": "3.7.4",
"oh-my-opencode-linux-arm64-musl": "3.7.4",
"oh-my-opencode-linux-x64": "3.7.4",
"oh-my-opencode-linux-x64-musl": "3.7.4",
"oh-my-opencode-windows-x64": "3.7.4",
"oh-my-opencode-darwin-arm64": "3.8.1",
"oh-my-opencode-darwin-x64": "3.8.1",
"oh-my-opencode-linux-arm64": "3.8.1",
"oh-my-opencode-linux-arm64-musl": "3.8.1",
"oh-my-opencode-linux-x64": "3.8.1",
"oh-my-opencode-linux-x64-musl": "3.8.1",
"oh-my-opencode-windows-x64": "3.8.1",
},
},
},
@@ -228,19 +228,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.7.4", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-0m84UiVlOC2gLSFIOTmCsxFCB9CmyWV9vGPYqfBFLoyDJmedevU3R5N4ze54W7jv4HSSxz02Zwr+QF5rkQANoA=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.8.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vbtS0WUFOZpufKzlX2G83fIDry3rpiXej8zNuXNCkx7hF34rK04rj0zeBH9dL+kdNV0Ys0Wl1rR1Mjto28UcAw=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.7.4", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Z2dQy8jmc6DuwbN9bafhOwjZBkAkTWlfLAz1tG6xVzMqTcp4YOrzrHFOBRNeFKpOC/x7yUpO3sq/YNCclloelw=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.8.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-gLz6dLNg9hr7roqBjaqlxta6+XYCs032/FiE0CiwypIBtYOq5EAgDVJ95JY5DQ2M+3Un028d50yMfwsfNfGlSw=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.7.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-TZIsK6Dl6yX6pSTocls91bjnvoY/6/kiGnmgdsoDKcPYZ7XuBQaJwH0dK7t9/sxuDI+wKhmtrmLwKSoYOIqsRw=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.8.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-teAIuHlR5xOAoUmA+e0bGzy3ikgIr+nCdyOPwHYm8jIp0aBUWAqbcdoQLeNTgenWpoM8vhHk+2xh4WcCeQzjEA=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.7.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UwPOoQP0+1eCKP/XTDsnLJDK5jayiL4VrKz0lfRRRojl1FWvInmQumnDnluvnxW6knU7dFM3yDddlZYG6tEgcw=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.8.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-VzBEq1H5dllEloouIoLdbw1icNUW99qmvErFrNj66mX42DNXK+f1zTtvBG8U6eeFfUBRRJoUjdCsvO65f8BkFA=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.7.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-+TeA0Bs5wK9EMfKiEEFfyfVqdBDUjDzN8POF8JJibN0GPy1oNIGGEWIJG2cvC5onpnYEvl448vkFbkCUK0g9SQ=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.8.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-8hDcb8s+wdQpQObSmiyaaTV0P/js2Bs9Lu+HmzrkKjuMLXXj/Gk7K0kKWMoEnMbMGfj86GfBHHIWmu9juI/SjA=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.7.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-YzX6wFtk8RoTHkAZkfLCVyCU4yjN8D7agj/jhOnFKW50fZYa8zX+/4KLZx0IfanVpXTgrs3iiuKoa87KLDfCxQ=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.8.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-idyH5bdYn7wrLkIkYr83omN83E2BjA/9DUHCX2we8VXbhDVbBgmMpUg8B8nKnd5NK/SyLHgRs5QqQJw8XBC0cQ=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.7.4", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-x39M2eFJI6pqv4go5Crf1H2SbPGFmXHIDNtbsSa5nRNcrqTisLrYGW8uXpOrqjntBeTAUBdwZmmoy6zgxHsz8w=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.8.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-O30L1PUF9aq1vSOyadcXQOLnDFSTvYn6cGd5huh0LAK/us0hGezoahtXegMdFtDXPIIREJlkRQhyJiafza7YgA=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.8.1",
"version": "3.8.3",
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -74,13 +74,13 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.8.1",
"oh-my-opencode-darwin-x64": "3.8.1",
"oh-my-opencode-linux-arm64": "3.8.1",
"oh-my-opencode-linux-arm64-musl": "3.8.1",
"oh-my-opencode-linux-x64": "3.8.1",
"oh-my-opencode-linux-x64-musl": "3.8.1",
"oh-my-opencode-windows-x64": "3.8.1"
"oh-my-opencode-darwin-arm64": "3.8.3",
"oh-my-opencode-darwin-x64": "3.8.3",
"oh-my-opencode-linux-arm64": "3.8.3",
"oh-my-opencode-linux-arm64-musl": "3.8.3",
"oh-my-opencode-linux-x64": "3.8.3",
"oh-my-opencode-linux-x64-musl": "3.8.3",
"oh-my-opencode-windows-x64": "3.8.3"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.8.1",
"version": "3.8.3",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "3.8.1",
"version": "3.8.3",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64-musl",
"version": "3.8.1",
"version": "3.8.3",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64",
"version": "3.8.1",
"version": "3.8.3",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64-musl",
"version": "3.8.1",
"version": "3.8.3",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64",
"version": "3.8.1",
"version": "3.8.3",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-windows-x64",
"version": "3.8.1",
"version": "3.8.3",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -6,12 +6,13 @@
*
* Routing:
* 1. GPT models (openai/*, github-copilot/gpt-*) → gpt.ts (GPT-5.2 optimized)
* 2. Default (Claude, etc.) → default.ts (Claude-optimized)
* 2. Gemini models (google/*, google-vertex/*) → gemini.ts (Gemini-optimized)
* 3. Default (Claude, etc.) → default.ts (Claude-optimized)
*/
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode, AgentPromptMetadata } from "../types"
import { isGptModel } from "../types"
import { isGptModel, isGeminiModel } from "../types"
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder"
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
import type { CategoryConfig } from "../../config/schema"
@@ -20,6 +21,7 @@ import { createAgentToolRestrictions } from "../../shared/permission-compat"
import { getDefaultAtlasPrompt } from "./default"
import { getGptAtlasPrompt } from "./gpt"
import { getGeminiAtlasPrompt } from "./gemini"
import {
getCategoryDescription,
buildAgentSelectionSection,
@@ -30,7 +32,7 @@ import {
const MODE: AgentMode = "primary"
export type AtlasPromptSource = "default" | "gpt"
export type AtlasPromptSource = "default" | "gpt" | "gemini"
/**
* Determines which Atlas prompt to use based on model.
@@ -39,6 +41,9 @@ export function getAtlasPromptSource(model?: string): AtlasPromptSource {
if (model && isGptModel(model)) {
return "gpt"
}
if (model && isGeminiModel(model)) {
return "gemini"
}
return "default"
}
@@ -58,6 +63,8 @@ export function getAtlasPrompt(model?: string): string {
switch (source) {
case "gpt":
return getGptAtlasPrompt()
case "gemini":
return getGeminiAtlasPrompt()
case "default":
default:
return getDefaultAtlasPrompt()

View File

@@ -1,5 +1,6 @@
export { ATLAS_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default"
export { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt"
export { ATLAS_GEMINI_SYSTEM_PROMPT, getGeminiAtlasPrompt } from "./gemini"
export {
getCategoryDescription,
buildAgentSelectionSection,

View File

@@ -6,6 +6,7 @@ export {
} from "./system-prompt"
export type { PrometheusPromptSource } from "./system-prompt"
export { PROMETHEUS_GPT_SYSTEM_PROMPT, getGptPrometheusPrompt } from "./gpt"
export { PROMETHEUS_GEMINI_SYSTEM_PROMPT, getGeminiPrometheusPrompt } from "./gemini"
// Re-export individual sections for granular access
export { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints"

View File

@@ -5,7 +5,8 @@ import { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode"
import { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template"
import { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary"
import { getGptPrometheusPrompt } from "./gpt"
import { isGptModel } from "../types"
import { getGeminiPrometheusPrompt } from "./gemini"
import { isGptModel, isGeminiModel } from "../types"
/**
* Combined Prometheus system prompt (Claude-optimized, default).
@@ -30,7 +31,7 @@ export const PROMETHEUS_PERMISSION = {
question: "allow" as const,
}
export type PrometheusPromptSource = "default" | "gpt"
export type PrometheusPromptSource = "default" | "gpt" | "gemini"
/**
* Determines which Prometheus prompt to use based on model.
@@ -39,12 +40,16 @@ export function getPrometheusPromptSource(model?: string): PrometheusPromptSourc
if (model && isGptModel(model)) {
return "gpt"
}
if (model && isGeminiModel(model)) {
return "gemini"
}
return "default"
}
/**
* Gets the appropriate Prometheus prompt based on model.
* GPT models → GPT-5.2 optimized prompt (XML-tagged, principle-driven)
* Gemini models → Gemini-optimized prompt (aggressive tool-call enforcement, thinking checkpoints)
* Default (Claude, etc.) → Claude-optimized prompt (modular sections)
*/
export function getPrometheusPrompt(model?: string): string {
@@ -53,6 +58,8 @@ export function getPrometheusPrompt(model?: string): string {
switch (source) {
case "gpt":
return getGptPrometheusPrompt()
case "gemini":
return getGeminiPrometheusPrompt()
case "default":
default:
return PROMETHEUS_SYSTEM_PROMPT

View File

@@ -6,12 +6,13 @@
*
* Routing:
* 1. GPT models (openai/*, github-copilot/gpt-*) -> gpt.ts (GPT-5.2 optimized)
* 2. Default (Claude, etc.) -> default.ts (Claude-optimized)
* 2. Gemini models (google/*, google-vertex/*) -> gemini.ts (Gemini-optimized)
* 3. Default (Claude, etc.) -> default.ts (Claude-optimized)
*/
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode } from "../types"
import { isGptModel } from "../types"
import { isGptModel, isGeminiModel } from "../types"
import type { AgentOverrideConfig } from "../../config/schema"
import {
createAgentToolRestrictions,
@@ -20,6 +21,7 @@ import {
import { buildDefaultSisyphusJuniorPrompt } from "./default"
import { buildGptSisyphusJuniorPrompt } from "./gpt"
import { buildGeminiSisyphusJuniorPrompt } from "./gemini"
const MODE: AgentMode = "subagent"
@@ -32,7 +34,7 @@ export const SISYPHUS_JUNIOR_DEFAULTS = {
temperature: 0.1,
} as const
export type SisyphusJuniorPromptSource = "default" | "gpt"
export type SisyphusJuniorPromptSource = "default" | "gpt" | "gemini"
/**
* Determines which Sisyphus-Junior prompt to use based on model.
@@ -41,6 +43,9 @@ export function getSisyphusJuniorPromptSource(model?: string): SisyphusJuniorPro
if (model && isGptModel(model)) {
return "gpt"
}
if (model && isGeminiModel(model)) {
return "gemini"
}
return "default"
}
@@ -57,6 +62,8 @@ export function buildSisyphusJuniorPrompt(
switch (source) {
case "gpt":
return buildGptSisyphusJuniorPrompt(useTaskSystem, promptAppend)
case "gemini":
return buildGeminiSisyphusJuniorPrompt(useTaskSystem, promptAppend)
case "default":
default:
return buildDefaultSisyphusJuniorPrompt(useTaskSystem, promptAppend)

View File

@@ -1,5 +1,6 @@
export { buildDefaultSisyphusJuniorPrompt } from "./default"
export { buildGptSisyphusJuniorPrompt } from "./gpt"
export { buildGeminiSisyphusJuniorPrompt } from "./gemini"
export {
SISYPHUS_JUNIOR_DEFAULTS,

View File

@@ -1,6 +1,11 @@
import type { AgentConfig } from "@opencode-ai/sdk";
import type { AgentMode, AgentPromptMetadata } from "./types";
import { isGptModel } from "./types";
import { isGptModel, isGeminiModel } from "./types";
import {
buildGeminiToolMandate,
buildGeminiDelegationOverride,
buildGeminiVerificationOverride,
} from "./sisyphus-gemini-overlays";
const MODE: AgentMode = "primary";
export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = {
@@ -548,7 +553,7 @@ export function createSisyphusAgent(
const tools = availableToolNames ? categorizeTools(availableToolNames) : [];
const skills = availableSkills ?? [];
const categories = availableCategories ?? [];
const prompt = availableAgents
let prompt = availableAgents
? buildDynamicSisyphusPrompt(
model,
availableAgents,
@@ -559,6 +564,15 @@ export function createSisyphusAgent(
)
: buildDynamicSisyphusPrompt(model, [], tools, skills, categories, useTaskSystem);
if (isGeminiModel(model)) {
prompt = prompt.replace(
"</intent_verbalization>",
`</intent_verbalization>\n\n${buildGeminiToolMandate()}`
);
prompt += "\n" + buildGeminiDelegationOverride();
prompt += "\n" + buildGeminiVerificationOverride();
}
const permission = {
question: "allow",
call_omo_agent: "deny",

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test";
import { isGptModel } from "./types";
import { isGptModel, isGeminiModel } from "./types";
describe("isGptModel", () => {
test("standard openai provider models", () => {
@@ -47,3 +47,47 @@ describe("isGptModel", () => {
expect(isGptModel("opencode/claude-opus-4-6")).toBe(false);
});
});
describe("isGeminiModel", () => {
test("#given google provider models #then returns true", () => {
expect(isGeminiModel("google/gemini-3-pro")).toBe(true);
expect(isGeminiModel("google/gemini-3-flash")).toBe(true);
expect(isGeminiModel("google/gemini-2.5-pro")).toBe(true);
});
test("#given google-vertex provider models #then returns true", () => {
expect(isGeminiModel("google-vertex/gemini-3-pro")).toBe(true);
expect(isGeminiModel("google-vertex/gemini-3-flash")).toBe(true);
});
test("#given github copilot gemini models #then returns true", () => {
expect(isGeminiModel("github-copilot/gemini-3-pro")).toBe(true);
expect(isGeminiModel("github-copilot/gemini-3-flash")).toBe(true);
});
test("#given litellm proxied gemini models #then returns true", () => {
expect(isGeminiModel("litellm/gemini-3-pro")).toBe(true);
expect(isGeminiModel("litellm/gemini-3-flash")).toBe(true);
expect(isGeminiModel("litellm/gemini-2.5-pro")).toBe(true);
});
test("#given other proxied gemini models #then returns true", () => {
expect(isGeminiModel("custom-provider/gemini-3-pro")).toBe(true);
expect(isGeminiModel("ollama/gemini-3-flash")).toBe(true);
});
test("#given gpt models #then returns false", () => {
expect(isGeminiModel("openai/gpt-5.2")).toBe(false);
expect(isGeminiModel("openai/o3-mini")).toBe(false);
expect(isGeminiModel("litellm/gpt-4o")).toBe(false);
});
test("#given claude models #then returns false", () => {
expect(isGeminiModel("anthropic/claude-opus-4-6")).toBe(false);
expect(isGeminiModel("anthropic/claude-sonnet-4-6")).toBe(false);
});
test("#given opencode provider #then returns false", () => {
expect(isGeminiModel("opencode/claude-opus-4-6")).toBe(false);
});
});

View File

@@ -80,6 +80,19 @@ export function isGptModel(model: string): boolean {
return GPT_MODEL_PREFIXES.some((prefix) => modelName.startsWith(prefix))
}
const GEMINI_PROVIDERS = ["google/", "google-vertex/"]
export function isGeminiModel(model: string): boolean {
if (GEMINI_PROVIDERS.some((prefix) => model.startsWith(prefix)))
return true
if (model.startsWith("github-copilot/") && extractModelName(model).toLowerCase().startsWith("gemini"))
return true
const modelName = extractModelName(model).toLowerCase()
return modelName.startsWith("gemini-")
}
export type BuiltinAgentName =
| "sisyphus"
| "hephaestus"

View File

@@ -25,7 +25,6 @@ import {
hasMoreFallbacks,
} from "../../shared/model-error-classifier"
import {
MIN_IDLE_TIME_MS,
POLLING_INTERVAL_MS,
TASK_CLEANUP_DELAY_MS,
} from "./constants"
@@ -43,6 +42,7 @@ import {
import { tryFallbackRetry } from "./fallback-retry-handler"
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver"
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
import { MESSAGE_STORAGE } from "../hook-message-injector"
import { join } from "node:path"
import { pruneStaleTasksAndNotifications } from "./task-poller"
@@ -740,61 +740,15 @@ export class BackgroundManager {
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const task = this.findBySession(sessionID)
if (!task || task.status !== "running") return
const startedAt = task.startedAt
if (!startedAt) return
// Edge guard: Require minimum elapsed time (5 seconds) before accepting idle
const elapsedMs = Date.now() - startedAt.getTime()
if (elapsedMs < MIN_IDLE_TIME_MS) {
const remainingMs = MIN_IDLE_TIME_MS - elapsedMs
if (!this.idleDeferralTimers.has(task.id)) {
log("[background-agent] Deferring early session.idle:", { elapsedMs, remainingMs, taskId: task.id })
const timer = setTimeout(() => {
this.idleDeferralTimers.delete(task.id)
this.handleEvent({ type: "session.idle", properties: { sessionID } })
}, remainingMs)
this.idleDeferralTimers.set(task.id, timer)
} else {
log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id })
}
return
}
// Edge guard: Verify session has actual assistant output before completing
this.validateSessionHasOutput(sessionID).then(async (hasValidOutput) => {
// Re-check status after async operation (could have been completed by polling)
if (task.status !== "running") {
log("[background-agent] Task status changed during validation, skipping:", { taskId: task.id, status: task.status })
return
}
if (!hasValidOutput) {
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
return
}
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
// Re-check status after async operation again
if (task.status !== "running") {
log("[background-agent] Task status changed during todo check, skipping:", { taskId: task.id, status: task.status })
return
}
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
return
}
await this.tryCompleteTask(task, "session.idle event")
}).catch(err => {
log("[background-agent] Error in session.idle handler:", err)
if (!props || typeof props !== "object") return
handleSessionIdleBackgroundEvent({
properties: props as Record<string, unknown>,
findBySession: (id) => this.findBySession(id),
idleDeferralTimers: this.idleDeferralTimers,
validateSessionHasOutput: (id) => this.validateSessionHasOutput(id),
checkSessionTodos: (id) => this.checkSessionTodos(id),
tryCompleteTask: (task, source) => this.tryCompleteTask(task, source),
emitIdleEvent: (sessionID) => this.handleEvent({ type: "session.idle", properties: { sessionID } }),
})
}

View File

@@ -0,0 +1,340 @@
import { describe, it, expect, mock } from "bun:test"
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
import type { BackgroundTask } from "./types"
import { MIN_IDLE_TIME_MS } from "./constants"
function createRunningTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
return {
id: "task-1",
sessionID: "ses-idle-1",
parentSessionID: "parent-ses-1",
parentMessageID: "msg-1",
description: "test idle handler",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 100)),
...overrides,
}
}
describe("handleSessionIdleBackgroundEvent", () => {
describe("#given no sessionID in properties", () => {
it("#then should do nothing", () => {
//#given
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: {},
findBySession: () => undefined,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given non-string sessionID in properties", () => {
it("#then should do nothing", () => {
//#given
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: 123 },
findBySession: () => undefined,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given no task found for session", () => {
it("#then should do nothing", () => {
//#given
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: "ses-unknown" },
findBySession: () => undefined,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given task is not running", () => {
it("#then should do nothing", () => {
//#given
const task = createRunningTask({ status: "completed" })
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given task has no startedAt", () => {
it("#then should do nothing", () => {
//#given
const task = createRunningTask({ startedAt: undefined })
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
describe("#given elapsed time < MIN_IDLE_TIME_MS", () => {
it("#when idle fires early #then should defer with timer", () => {
//#given
const realDateNow = Date.now
const baseNow = realDateNow()
const task = createRunningTask({ startedAt: new Date(baseNow) })
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>()
const emitIdleEvent = mock(() => {})
try {
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - 100)
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers,
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask: () => Promise.resolve(true),
emitIdleEvent,
})
//#then
expect(idleDeferralTimers.has(task.id)).toBe(true)
expect(emitIdleEvent).not.toHaveBeenCalled()
} finally {
clearTimeout(idleDeferralTimers.get(task.id)!)
Date.now = realDateNow
}
})
it("#when idle already deferred #then should not create duplicate timer", () => {
//#given
const realDateNow = Date.now
const baseNow = realDateNow()
const task = createRunningTask({ startedAt: new Date(baseNow) })
const existingTimer = setTimeout(() => {}, 99999)
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>([
[task.id, existingTimer],
])
const emitIdleEvent = mock(() => {})
try {
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - 100)
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers,
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask: () => Promise.resolve(true),
emitIdleEvent,
})
//#then
expect(idleDeferralTimers.get(task.id)).toBe(existingTimer)
} finally {
clearTimeout(existingTimer)
Date.now = realDateNow
}
})
it("#when deferred timer fires #then should emit idle event", async () => {
//#given
const realDateNow = Date.now
const baseNow = realDateNow()
const task = createRunningTask({ startedAt: new Date(baseNow) })
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>()
const emitIdleEvent = mock(() => {})
const remainingMs = 50
try {
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - remainingMs)
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers,
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask: () => Promise.resolve(true),
emitIdleEvent,
})
//#then - wait for deferred timer
await new Promise((resolve) => setTimeout(resolve, remainingMs + 50))
expect(emitIdleEvent).toHaveBeenCalledWith(task.sessionID)
expect(idleDeferralTimers.has(task.id)).toBe(false)
} finally {
Date.now = realDateNow
}
})
})
describe("#given elapsed time >= MIN_IDLE_TIME_MS", () => {
it("#when session has valid output and no incomplete todos #then should complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).toHaveBeenCalledWith(task, "session.idle event")
})
it("#when session has no valid output #then should not complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(false),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).not.toHaveBeenCalled()
})
it("#when task has incomplete todos #then should not complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(true),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).not.toHaveBeenCalled()
})
it("#when task status changes during validation #then should not complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: async () => {
task.status = "completed"
return true
},
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).not.toHaveBeenCalled()
})
it("#when task status changes during todo check #then should not complete task", async () => {
//#given
const task = createRunningTask()
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: async () => {
task.status = "cancelled"
return false
},
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).not.toHaveBeenCalled()
})
})
})

View File

@@ -104,6 +104,65 @@ ALL three must be YES. "Probably" = NO. "I think so" = NO. Investigate until CER
**DO NOT proceed to the next task until all 4 phases are complete and the gate passes.**`
export const VERIFICATION_REMINDER_GEMINI = `**THE SUBAGENT HAS FINISHED. THEIR WORK IS EXTREMELY SUSPICIOUS.**
The subagent CLAIMS this task is done. Based on thousands of executions, subagent claims are FALSE more often than true.
They ROUTINELY:
- Ship code with syntax errors they didn't bother to check
- Create stub implementations with TODOs and call it "done"
- Write tests that pass trivially (testing nothing meaningful)
- Implement logic that does NOT match what was requested
- Add features nobody asked for and call it "improvement"
- Report "all tests pass" when they didn't run any tests
**This is NOT a theoretical warning. This WILL happen on this task. Assume the work is BROKEN.**
**YOU MUST VERIFY WITH ACTUAL TOOL CALLS. NOT REASONING. TOOL CALLS.**
Thinking "it looks correct" is NOT verification. Running \`lsp_diagnostics\` IS.
---
**PHASE 1: READ THE CODE FIRST (DO NOT SKIP — DO NOT RUN TESTS YET)**
Read the code FIRST so you know what you're testing.
1. \`Bash("git diff --stat")\` — see exactly which files changed.
2. \`Read\` EVERY changed file — no exceptions, no skimming.
3. For EACH file:
- Does this code ACTUALLY do what the task required? RE-READ the task spec.
- Any stubs, TODOs, placeholders? \`Grep\` for TODO, FIXME, HACK, xxx
- Anti-patterns? \`Grep\` for \`as any\`, \`@ts-ignore\`, empty catch
- Scope creep? Did the subagent add things NOT in the task spec?
4. Cross-check EVERY claim against actual code.
**If you cannot explain what every changed line does, GO BACK AND READ AGAIN.**
**PHASE 2: RUN AUTOMATED CHECKS**
1. \`lsp_diagnostics\` on EACH changed file — ZERO new errors. ACTUALLY RUN THIS.
2. Run tests for changed modules, then full suite. ACTUALLY RUN THESE.
3. Build/typecheck — exit 0.
If Phase 1 found issues but Phase 2 passes: Phase 2 is WRONG. Fix the code.
**PHASE 3: HANDS-ON QA (MANDATORY for user-facing changes)**
- **Frontend/UI**: \`/playwright\`
- **TUI/CLI**: \`interactive_bash\`
- **API/Backend**: \`Bash\` with curl
**If user-facing and you did not run it, you are shipping UNTESTED BROKEN work.**
**PHASE 4: GATE DECISION**
1. Can I explain what EVERY changed line does? (If no → Phase 1)
2. Did I SEE it work via tool calls? (If user-facing and no → Phase 3)
3. Am I confident nothing is broken? (If no → broader tests)
ALL three must be YES. "Probably" = NO. "I think so" = NO.
**DO NOT proceed to the next task until all 4 phases are complete.**`
export const ORCHESTRATOR_DELEGATION_REQUIRED = `
---

View File

@@ -0,0 +1,93 @@
const { describe, expect, test, beforeEach, afterEach, spyOn } = require("bun:test")
const { createSessionNotification } = require("./session-notification")
const { setMainSession, subagentSessions, _resetForTesting } = require("../features/claude-code-session-state")
const utils = require("./session-notification-utils")
describe("session-notification input-needed events", () => {
let notificationCalls: string[]
function createMockPluginInput() {
return {
$: async (cmd: TemplateStringsArray | string, ...values: unknown[]) => {
const cmdStr = typeof cmd === "string"
? cmd
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
notificationCalls.push(cmdStr)
}
return { stdout: "", stderr: "", exitCode: 0 }
},
client: {
session: {
todo: async () => ({ data: [] }),
},
},
directory: "/tmp/test",
}
}
beforeEach(() => {
_resetForTesting()
notificationCalls = []
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
})
afterEach(() => {
subagentSessions.clear()
_resetForTesting()
})
test("sends question notification when question tool asks for input", async () => {
const sessionID = "main-question"
setMainSession(sessionID)
const hook = createSessionNotification(createMockPluginInput())
await hook({
event: {
type: "tool.execute.before",
properties: {
sessionID,
tool: "question",
args: {
questions: [
{
question: "Which branch should we use?",
options: [{ label: "main" }, { label: "dev" }],
},
],
},
},
},
})
expect(notificationCalls).toHaveLength(1)
expect(notificationCalls[0]).toContain("Agent is asking a question")
})
test("sends permission notification for permission events", async () => {
const sessionID = "main-permission"
setMainSession(sessionID)
const hook = createSessionNotification(createMockPluginInput())
await hook({
event: {
type: "permission.asked",
properties: {
sessionID,
},
},
})
expect(notificationCalls).toHaveLength(1)
expect(notificationCalls[0]).toContain("Agent needs permission to continue")
})
})
export {}

View File

@@ -15,6 +15,8 @@ import { createIdleNotificationScheduler } from "./session-notification-schedule
interface SessionNotificationConfig {
title?: string
message?: string
questionMessage?: string
permissionMessage?: string
playSound?: boolean
soundPath?: string
/** Delay in ms before sending notification to confirm session is still idle (default: 1500) */
@@ -36,6 +38,8 @@ export function createSessionNotification(
const mergedConfig = {
title: "OpenCode",
message: "Agent is ready for input",
questionMessage: "Agent is asking a question",
permissionMessage: "Agent needs permission to continue",
playSound: false,
soundPath: defaultSoundPath,
idleConfirmationDelay: 1500,
@@ -53,6 +57,56 @@ export function createSessionNotification(
playSound: playSessionNotificationSound,
})
const QUESTION_TOOLS = new Set(["question", "ask_user_question", "askuserquestion"])
const PERMISSION_EVENTS = new Set(["permission.ask", "permission.asked", "permission.updated", "permission.requested"])
const PERMISSION_HINT_PATTERN = /\b(permission|approve|approval|allow|deny|consent)\b/i
const getSessionID = (properties: Record<string, unknown> | undefined): string | undefined => {
const sessionID = properties?.sessionID
if (typeof sessionID === "string" && sessionID.length > 0) return sessionID
const sessionId = properties?.sessionId
if (typeof sessionId === "string" && sessionId.length > 0) return sessionId
const info = properties?.info as Record<string, unknown> | undefined
const infoSessionID = info?.sessionID
if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID
const infoSessionId = info?.sessionId
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId
return undefined
}
const shouldNotifyForSession = (sessionID: string): boolean => {
if (subagentSessions.has(sessionID)) return false
const mainSessionID = getMainSessionID()
if (mainSessionID && sessionID !== mainSessionID) return false
return true
}
const getEventToolName = (properties: Record<string, unknown> | undefined): string | undefined => {
const tool = properties?.tool
if (typeof tool === "string" && tool.length > 0) return tool
const name = properties?.name
if (typeof name === "string" && name.length > 0) return name
return undefined
}
const getQuestionText = (properties: Record<string, unknown> | undefined): string => {
const args = properties?.args as Record<string, unknown> | undefined
const questions = args?.questions
if (!Array.isArray(questions) || questions.length === 0) return ""
const firstQuestion = questions[0] as Record<string, unknown> | undefined
const questionText = firstQuestion?.question
return typeof questionText === "string" ? questionText : ""
}
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (currentPlatform === "unsupported") return
@@ -68,14 +122,10 @@ export function createSessionNotification(
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
const sessionID = getSessionID(props)
if (!sessionID) return
if (subagentSessions.has(sessionID)) return
// Only trigger notifications for the main session (not subagent sessions)
const mainSessionID = getMainSessionID()
if (mainSessionID && sessionID !== mainSessionID) return
if (!shouldNotifyForSession(sessionID)) return
scheduler.scheduleIdleNotification(sessionID)
return
@@ -83,17 +133,47 @@ export function createSessionNotification(
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const sessionID = getSessionID({ ...props, info })
if (sessionID) {
scheduler.markSessionActivity(sessionID)
}
return
}
if (PERMISSION_EVENTS.has(event.type)) {
const sessionID = getSessionID(props)
if (!sessionID) return
if (!shouldNotifyForSession(sessionID)) return
scheduler.markSessionActivity(sessionID)
await sendSessionNotification(ctx, currentPlatform, mergedConfig.title, mergedConfig.permissionMessage)
if (mergedConfig.playSound && mergedConfig.soundPath) {
await playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
}
return
}
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = props?.sessionID as string | undefined
const sessionID = getSessionID(props)
if (sessionID) {
scheduler.markSessionActivity(sessionID)
if (event.type === "tool.execute.before") {
const toolName = getEventToolName(props)?.toLowerCase()
if (toolName && QUESTION_TOOLS.has(toolName)) {
if (!shouldNotifyForSession(sessionID)) return
const questionText = getQuestionText(props)
const message = PERMISSION_HINT_PATTERN.test(questionText)
? mergedConfig.permissionMessage
: mergedConfig.questionMessage
await sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message)
if (mergedConfig.playSound && mergedConfig.soundPath) {
await playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
}
}
}
}
return
}

View File

@@ -1,7 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { existsSync, realpathSync } from "fs"
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path"
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
import { log } from "../../shared"
@@ -14,7 +14,7 @@ type GuardArgs = {
const MAX_TRACKED_SESSIONS = 256
export const MAX_TRACKED_PATHS_PER_SESSION = 1024
const OUTSIDE_SESSION_MESSAGE = "Path must be inside session directory."
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -37,6 +37,8 @@ function isPathInsideDirectory(pathToCheck: string, directory: string): boolean
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))
}
function toCanonicalPath(absolutePath: string): string {
let canonicalPath = absolutePath
@@ -73,7 +75,6 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
const readPermissionsBySession = new Map<string, Set<string>>()
const sessionLastAccess = new Map<string, number>()
const canonicalSessionRoot = toCanonicalPath(resolveInputPath(ctx, ctx.directory))
const sisyphusRoot = join(canonicalSessionRoot, ".sisyphus") + sep
const touchSession = (sessionID: string): void => {
sessionLastAccess.set(sessionID, Date.now())
@@ -174,16 +175,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
const isInsideSessionDirectory = isPathInsideDirectory(canonicalPath, canonicalSessionRoot)
if (!isInsideSessionDirectory) {
if (toolName === "read") {
return
}
log("[write-existing-file-guard] Blocking write outside session directory", {
sessionID: input.sessionID,
filePath,
resolvedPath,
})
throw new Error(OUTSIDE_SESSION_MESSAGE)
return
}
if (toolName === "read") {
@@ -206,7 +198,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
return
}
const isSisyphusPath = canonicalPath.startsWith(sisyphusRoot)
const isSisyphusPath = canonicalPath.includes("/.sisyphus/")
if (isSisyphusPath) {
log("[write-existing-file-guard] Allowing .sisyphus/** overwrite", {
sessionID: input.sessionID,

View File

@@ -7,7 +7,6 @@ import { MAX_TRACKED_PATHS_PER_SESSION } from "./hook"
import { createWriteExistingFileGuardHook } from "./index"
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
const OUTSIDE_SESSION_MESSAGE = "Path must be inside session directory."
type Hook = ReturnType<typeof createWriteExistingFileGuardHook>
@@ -339,7 +338,7 @@ describe("createWriteExistingFileGuardHook", () => {
).resolves.toBeDefined()
})
test("#given existing file outside session directory #when write executes #then blocks", async () => {
test("#given existing file outside session directory #when write executes #then allows", async () => {
const outsideDir = mkdtempSync(join(tmpdir(), "write-existing-file-guard-outside-"))
try {
@@ -349,9 +348,9 @@ describe("createWriteExistingFileGuardHook", () => {
await expect(
invoke({
tool: "write",
outputArgs: { filePath: outsideFile, content: "attempted overwrite" },
outputArgs: { filePath: outsideFile, content: "allowed overwrite" },
})
).rejects.toThrow(OUTSIDE_SESSION_MESSAGE)
).resolves.toBeDefined()
} finally {
rmSync(outsideDir, { recursive: true, force: true })
}

View File

@@ -1,53 +1,58 @@
import type { OhMyOpenCodeConfig } from "../config"
import type { PluginContext } from "./types"
import type { OhMyOpenCodeConfig } from "../config";
import type { PluginContext } from "./types";
import {
clearSessionAgent,
getMainSessionID,
getSessionAgent,
setMainSession,
subagentSessions,
syncSubagentSessions,
setMainSession,
updateSessionAgent,
} from "../features/claude-code-session-state"
import { resetMessageCursor } from "../shared"
import { lspManager } from "../tools"
import { shouldRetryError } from "../shared/model-error-classifier"
import { clearPendingModelFallback, clearSessionFallbackChain, setPendingModelFallback } from "../hooks/model-fallback/hook"
import { log } from "../shared/logger"
import { clearSessionModel, setSessionModel } from "../shared/session-model-state"
} from "../features/claude-code-session-state";
import {
clearPendingModelFallback,
clearSessionFallbackChain,
setPendingModelFallback,
} from "../hooks/model-fallback/hook";
import { resetMessageCursor } from "../shared";
import { log } from "../shared/logger";
import { shouldRetryError } from "../shared/model-error-classifier";
import { clearSessionModel, setSessionModel } from "../shared/session-model-state";
import { deleteSessionTools } from "../shared/session-tools-store";
import { lspManager } from "../tools";
import type { CreatedHooks } from "../create-hooks"
import type { Managers } from "../create-managers"
import { normalizeSessionStatusToIdle } from "./session-status-normalizer"
import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles"
import type { CreatedHooks } from "../create-hooks";
import type { Managers } from "../create-managers";
import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles";
import { normalizeSessionStatusToIdle } from "./session-status-normalizer";
type FirstMessageVariantGate = {
markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void
clear: (sessionID: string) => void
}
markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void;
clear: (sessionID: string) => void;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
return typeof value === "object" && value !== null;
}
function normalizeFallbackModelID(modelID: string): string {
return modelID
.replace(/-thinking$/i, "")
.replace(/-max$/i, "")
.replace(/-high$/i, "")
.replace(/-high$/i, "");
}
function extractErrorName(error: unknown): string | undefined {
if (isRecord(error) && typeof error.name === "string") return error.name
if (error instanceof Error) return error.name
return undefined
if (isRecord(error) && typeof error.name === "string") return error.name;
if (error instanceof Error) return error.name;
return undefined;
}
function extractErrorMessage(error: unknown): string {
if (!error) return ""
if (typeof error === "string") return error
if (error instanceof Error) return error.message
if (!error) return "";
if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
if (isRecord(error)) {
const candidates: unknown[] = [
@@ -56,116 +61,112 @@ function extractErrorMessage(error: unknown): string {
error.error,
isRecord(error.data) ? error.data.error : undefined,
error.cause,
]
];
for (const candidate of candidates) {
if (isRecord(candidate) && typeof candidate.message === "string" && candidate.message.length > 0) {
return candidate.message
return candidate.message;
}
}
}
try {
return JSON.stringify(error)
return JSON.stringify(error);
} catch {
return String(error)
return String(error);
}
}
function extractProviderModelFromErrorMessage(
message: string,
): { providerID?: string; modelID?: string } {
const lower = message.toLowerCase()
function extractProviderModelFromErrorMessage(message: string): { providerID?: string; modelID?: string } {
const lower = message.toLowerCase();
const providerModel = lower.match(/model\s+not\s+found:\s*([a-z0-9_-]+)\s*\/\s*([a-z0-9._-]+)/i)
const providerModel = lower.match(/model\s+not\s+found:\s*([a-z0-9_-]+)\s*\/\s*([a-z0-9._-]+)/i);
if (providerModel) {
return {
providerID: providerModel[1],
modelID: providerModel[2],
}
};
}
const modelOnly = lower.match(/unknown\s+provider\s+for\s+model\s+([a-z0-9._-]+)/i)
const modelOnly = lower.match(/unknown\s+provider\s+for\s+model\s+([a-z0-9._-]+)/i);
if (modelOnly) {
return {
modelID: modelOnly[1],
}
};
}
return {}
return {};
}
type EventInput = Parameters<
NonNullable<NonNullable<CreatedHooks["writeExistingFileGuard"]>["event"]>
>[0]
type EventInput = Parameters<NonNullable<NonNullable<CreatedHooks["writeExistingFileGuard"]>["event"]>>[0];
export function createEventHandler(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
firstMessageVariantGate: FirstMessageVariantGate
managers: Managers
hooks: CreatedHooks
ctx: PluginContext;
pluginConfig: OhMyOpenCodeConfig;
firstMessageVariantGate: FirstMessageVariantGate;
managers: Managers;
hooks: CreatedHooks;
}): (input: EventInput) => Promise<void> {
const { ctx, firstMessageVariantGate, managers, hooks } = args
const { ctx, firstMessageVariantGate, managers, hooks } = args;
const pluginContext = ctx as {
directory: string
directory: string;
client: {
session: {
abort: (input: { path: { id: string } }) => Promise<unknown>
abort: (input: { path: { id: string } }) => Promise<unknown>;
prompt: (input: {
path: { id: string }
body: { parts: Array<{ type: "text"; text: string }> }
query: { directory: string }
}) => Promise<unknown>
}
}
}
path: { id: string };
body: { parts: Array<{ type: "text"; text: string }> };
query: { directory: string };
}) => Promise<unknown>;
};
};
};
const isRuntimeFallbackEnabled =
hooks.runtimeFallback !== null &&
hooks.runtimeFallback !== undefined &&
(typeof args.pluginConfig.runtime_fallback === "boolean"
? args.pluginConfig.runtime_fallback
: (args.pluginConfig.runtime_fallback?.enabled ?? false))
: (args.pluginConfig.runtime_fallback?.enabled ?? false));
// Avoid triggering multiple abort+continue cycles for the same failing assistant message.
const lastHandledModelErrorMessageID = new Map<string, string>()
const lastHandledRetryStatusKey = new Map<string, string>()
const lastKnownModelBySession = new Map<string, { providerID: string; modelID: string }>()
const lastHandledModelErrorMessageID = new Map<string, string>();
const lastHandledRetryStatusKey = new Map<string, string>();
const lastKnownModelBySession = new Map<string, { providerID: string; modelID: string }>();
const dispatchToHooks = async (input: EventInput): Promise<void> => {
await Promise.resolve(hooks.autoUpdateChecker?.event?.(input))
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input))
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input))
await Promise.resolve(hooks.sessionNotification?.(input))
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input))
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input))
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input))
await Promise.resolve(hooks.directoryAgentsInjector?.event?.(input))
await Promise.resolve(hooks.directoryReadmeInjector?.event?.(input))
await Promise.resolve(hooks.rulesInjector?.event?.(input))
await Promise.resolve(hooks.thinkMode?.event?.(input))
await Promise.resolve(hooks.anthropicContextWindowLimitRecovery?.event?.(input))
await Promise.resolve(hooks.runtimeFallback?.event?.(input))
await Promise.resolve(hooks.agentUsageReminder?.event?.(input))
await Promise.resolve(hooks.categorySkillReminder?.event?.(input))
await Promise.resolve(hooks.interactiveBashSession?.event?.(input as EventInput))
await Promise.resolve(hooks.ralphLoop?.event?.(input))
await Promise.resolve(hooks.stopContinuationGuard?.event?.(input))
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input))
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input))
await Promise.resolve(hooks.atlasHook?.handler?.(input))
}
await Promise.resolve(hooks.autoUpdateChecker?.event?.(input));
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input));
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input));
await Promise.resolve(hooks.sessionNotification?.(input));
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input));
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input));
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input));
await Promise.resolve(hooks.directoryAgentsInjector?.event?.(input));
await Promise.resolve(hooks.directoryReadmeInjector?.event?.(input));
await Promise.resolve(hooks.rulesInjector?.event?.(input));
await Promise.resolve(hooks.thinkMode?.event?.(input));
await Promise.resolve(hooks.anthropicContextWindowLimitRecovery?.event?.(input));
await Promise.resolve(hooks.runtimeFallback?.event?.(input));
await Promise.resolve(hooks.agentUsageReminder?.event?.(input));
await Promise.resolve(hooks.categorySkillReminder?.event?.(input));
await Promise.resolve(hooks.interactiveBashSession?.event?.(input as EventInput));
await Promise.resolve(hooks.ralphLoop?.event?.(input));
await Promise.resolve(hooks.stopContinuationGuard?.event?.(input));
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input));
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input));
await Promise.resolve(hooks.atlasHook?.handler?.(input));
};
const recentSyntheticIdles = new Map<string, number>()
const recentRealIdles = new Map<string, number>()
const DEDUP_WINDOW_MS = 500
const recentSyntheticIdles = new Map<string, number>();
const recentRealIdles = new Map<string, number>();
const DEDUP_WINDOW_MS = 500;
const shouldAutoRetrySession = (sessionID: string): boolean => {
if (syncSubagentSessions.has(sessionID)) return true
const mainSessionID = getMainSessionID()
if (mainSessionID) return sessionID === mainSessionID
if (syncSubagentSessions.has(sessionID)) return true;
const mainSessionID = getMainSessionID();
if (mainSessionID) return sessionID === mainSessionID;
// Headless runs (or resumed sessions) may not emit session.created, so mainSessionID can be unset.
// In that case, treat any non-subagent session as the "main" interactive session.
return !subagentSessions.has(sessionID)
}
return !subagentSessions.has(sessionID);
};
return async (input): Promise<void> => {
pruneRecentSyntheticIdles({
@@ -173,97 +174,98 @@ export function createEventHandler(args: {
recentRealIdles,
now: Date.now(),
dedupWindowMs: DEDUP_WINDOW_MS,
})
});
if (input.event.type === "session.idle") {
const sessionID = (input.event.properties as Record<string, unknown> | undefined)?.sessionID as string | undefined
const sessionID = (input.event.properties as Record<string, unknown> | undefined)?.sessionID as
| string
| undefined;
if (sessionID) {
const emittedAt = recentSyntheticIdles.get(sessionID)
const emittedAt = recentSyntheticIdles.get(sessionID);
if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) {
recentSyntheticIdles.delete(sessionID)
return
recentSyntheticIdles.delete(sessionID);
return;
}
recentRealIdles.set(sessionID, Date.now())
recentRealIdles.set(sessionID, Date.now());
}
}
await dispatchToHooks(input)
await dispatchToHooks(input);
const syntheticIdle = normalizeSessionStatusToIdle(input)
const syntheticIdle = normalizeSessionStatusToIdle(input);
if (syntheticIdle) {
const sessionID = (syntheticIdle.event.properties as Record<string, unknown>)?.sessionID as string
const emittedAt = recentRealIdles.get(sessionID)
const sessionID = (syntheticIdle.event.properties as Record<string, unknown>)?.sessionID as string;
const emittedAt = recentRealIdles.get(sessionID);
if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) {
recentRealIdles.delete(sessionID)
return
recentRealIdles.delete(sessionID);
return;
}
recentSyntheticIdles.set(sessionID, Date.now())
await dispatchToHooks(syntheticIdle as EventInput)
recentSyntheticIdles.set(sessionID, Date.now());
await dispatchToHooks(syntheticIdle as EventInput);
}
const { event } = input
const props = event.properties as Record<string, unknown> | undefined
const { event } = input;
const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.created") {
const sessionInfo = props?.info as
| { id?: string; title?: string; parentID?: string }
| undefined
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined;
if (!sessionInfo?.parentID) {
setMainSession(sessionInfo?.id)
setMainSession(sessionInfo?.id);
}
firstMessageVariantGate.markSessionCreated(sessionInfo)
firstMessageVariantGate.markSessionCreated(sessionInfo);
await managers.tmuxSessionManager.onSessionCreated(
event as {
type: string
type: string;
properties?: {
info?: { id?: string; parentID?: string; title?: string }
}
info?: { id?: string; parentID?: string; title?: string };
};
},
)
);
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
const sessionInfo = props?.info as { id?: string } | undefined;
if (sessionInfo?.id === getMainSessionID()) {
setMainSession(undefined)
setMainSession(undefined);
}
if (sessionInfo?.id) {
clearSessionAgent(sessionInfo.id)
lastHandledModelErrorMessageID.delete(sessionInfo.id)
lastHandledRetryStatusKey.delete(sessionInfo.id)
lastKnownModelBySession.delete(sessionInfo.id)
clearPendingModelFallback(sessionInfo.id)
clearSessionFallbackChain(sessionInfo.id)
resetMessageCursor(sessionInfo.id)
firstMessageVariantGate.clear(sessionInfo.id)
clearSessionModel(sessionInfo.id)
syncSubagentSessions.delete(sessionInfo.id)
await managers.skillMcpManager.disconnectSession(sessionInfo.id)
await lspManager.cleanupTempDirectoryClients()
clearSessionAgent(sessionInfo.id);
lastHandledModelErrorMessageID.delete(sessionInfo.id);
lastHandledRetryStatusKey.delete(sessionInfo.id);
lastKnownModelBySession.delete(sessionInfo.id);
clearPendingModelFallback(sessionInfo.id);
clearSessionFallbackChain(sessionInfo.id);
resetMessageCursor(sessionInfo.id);
firstMessageVariantGate.clear(sessionInfo.id);
clearSessionModel(sessionInfo.id);
syncSubagentSessions.delete(sessionInfo.id);
deleteSessionTools(sessionInfo.id);
await managers.skillMcpManager.disconnectSession(sessionInfo.id);
await lspManager.cleanupTempDirectoryClients();
await managers.tmuxSessionManager.onSessionDeleted({
sessionID: sessionInfo.id,
})
});
}
}
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const agent = info?.agent as string | undefined
const role = info?.role as string | undefined
const info = props?.info as Record<string, unknown> | undefined;
const sessionID = info?.sessionID as string | undefined;
const agent = info?.agent as string | undefined;
const role = info?.role as string | undefined;
if (sessionID && role === "user") {
if (agent) {
updateSessionAgent(sessionID, agent)
updateSessionAgent(sessionID, agent);
}
const providerID = info?.providerID as string | undefined
const modelID = info?.modelID as string | undefined
const providerID = info?.providerID as string | undefined;
const modelID = info?.modelID as string | undefined;
if (providerID && modelID) {
lastKnownModelBySession.set(sessionID, { providerID, modelID })
setSessionModel(sessionID, { providerID, modelID })
lastKnownModelBySession.set(sessionID, { providerID, modelID });
setSessionModel(sessionID, { providerID, modelID });
}
}
@@ -271,132 +273,128 @@ export function createEventHandler(args: {
// session.error events are not guaranteed for all providers, so we also observe message.updated.
if (sessionID && role === "assistant" && !isRuntimeFallbackEnabled) {
try {
const assistantMessageID = info?.id as string | undefined
const assistantError = info?.error
const assistantMessageID = info?.id as string | undefined;
const assistantError = info?.error;
if (assistantMessageID && assistantError) {
const lastHandled = lastHandledModelErrorMessageID.get(sessionID)
const lastHandled = lastHandledModelErrorMessageID.get(sessionID);
if (lastHandled === assistantMessageID) {
return
return;
}
const errorName = extractErrorName(assistantError)
const errorMessage = extractErrorMessage(assistantError)
const errorInfo = { name: errorName, message: errorMessage }
const errorName = extractErrorName(assistantError);
const errorMessage = extractErrorMessage(assistantError);
const errorInfo = { name: errorName, message: errorMessage };
if (shouldRetryError(errorInfo)) {
// Prefer the agent/model/provider from the assistant message payload.
let agentName = agent ?? getSessionAgent(sessionID)
let agentName = agent ?? getSessionAgent(sessionID);
if (!agentName && sessionID === getMainSessionID()) {
if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) {
agentName = "sisyphus"
agentName = "sisyphus";
} else if (errorMessage.includes("gpt-5")) {
agentName = "hephaestus"
agentName = "hephaestus";
} else {
agentName = "sisyphus"
agentName = "sisyphus";
}
}
if (agentName) {
const currentProvider = (info?.providerID as string | undefined) ?? "opencode"
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6"
const currentModel = normalizeFallbackModelID(rawModel)
const currentProvider = (info?.providerID as string | undefined) ?? "opencode";
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6";
const currentModel = normalizeFallbackModelID(rawModel);
const setFallback = setPendingModelFallback(
sessionID,
agentName,
currentProvider,
currentModel,
)
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID)
if (
setFallback &&
shouldAutoRetrySession(sessionID) &&
!hooks.stopContinuationGuard?.isStopped(sessionID)
) {
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID);
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {})
.catch(() => {});
}
}
}
}
} catch (err) {
log("[event] model-fallback error in message.updated:", { sessionID, error: err })
log("[event] model-fallback error in message.updated:", { sessionID, error: err });
}
}
}
if (event.type === "session.status") {
const sessionID = props?.sessionID as string | undefined
const status = props?.status as
| { type?: string; attempt?: number; message?: string; next?: number }
| undefined
const sessionID = props?.sessionID as string | undefined;
const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined;
if (sessionID && status?.type === "retry") {
try {
const retryMessage = typeof status.message === "string" ? status.message : ""
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`
const retryMessage = typeof status.message === "string" ? status.message : "";
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`;
if (lastHandledRetryStatusKey.get(sessionID) === retryKey) {
return
return;
}
lastHandledRetryStatusKey.set(sessionID, retryKey)
lastHandledRetryStatusKey.set(sessionID, retryKey);
const errorInfo = { name: undefined as string | undefined, message: retryMessage }
const errorInfo = { name: undefined as string | undefined, message: retryMessage };
if (shouldRetryError(errorInfo)) {
let agentName = getSessionAgent(sessionID)
let agentName = getSessionAgent(sessionID);
if (!agentName && sessionID === getMainSessionID()) {
if (retryMessage.includes("claude-opus") || retryMessage.includes("opus")) {
agentName = "sisyphus"
agentName = "sisyphus";
} else if (retryMessage.includes("gpt-5")) {
agentName = "hephaestus"
agentName = "hephaestus";
} else {
agentName = "sisyphus"
agentName = "sisyphus";
}
}
if (agentName) {
const parsed = extractProviderModelFromErrorMessage(retryMessage)
const lastKnown = lastKnownModelBySession.get(sessionID)
const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode"
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6"
currentModel = normalizeFallbackModelID(currentModel)
const parsed = extractProviderModelFromErrorMessage(retryMessage);
const lastKnown = lastKnownModelBySession.get(sessionID);
const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode";
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6";
currentModel = normalizeFallbackModelID(currentModel);
const setFallback = setPendingModelFallback(
sessionID,
agentName,
currentProvider,
currentModel,
)
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
if (
setFallback &&
shouldAutoRetrySession(sessionID) &&
!hooks.stopContinuationGuard?.isStopped(sessionID)
) {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {})
.catch(() => {});
}
}
}
} catch (err) {
log("[event] model-fallback error in session.status:", { sessionID, error: err })
log("[event] model-fallback error in session.status:", { sessionID, error: err });
}
}
}
if (event.type === "session.error") {
try {
const sessionID = props?.sessionID as string | undefined
const error = props?.error
const sessionID = props?.sessionID as string | undefined;
const error = props?.error;
const errorName = extractErrorName(error)
const errorMessage = extractErrorMessage(error)
const errorInfo = { name: errorName, message: errorMessage }
const errorName = extractErrorName(error);
const errorMessage = extractErrorMessage(error);
const errorInfo = { name: errorName, message: errorMessage };
// First, try session recovery for internal errors (thinking blocks, tool results, etc.)
if (hooks.sessionRecovery?.isRecoverableError(error)) {
@@ -405,8 +403,8 @@ export function createEventHandler(args: {
role: "assistant" as const,
sessionID,
error,
}
const recovered = await hooks.sessionRecovery.handleSessionRecovery(messageInfo)
};
const recovered = await hooks.sessionRecovery.handleSessionRecovery(messageInfo);
if (
recovered &&
@@ -420,53 +418,52 @@ export function createEventHandler(args: {
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {})
.catch(() => {});
}
}
}
// Second, try model fallback for model errors (rate limit, quota, provider issues, etc.)
else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled) {
let agentName = getSessionAgent(sessionID)
let agentName = getSessionAgent(sessionID);
if (!agentName && sessionID === getMainSessionID()) {
if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) {
agentName = "sisyphus"
agentName = "sisyphus";
} else if (errorMessage.includes("gpt-5")) {
agentName = "hephaestus"
agentName = "hephaestus";
} else {
agentName = "sisyphus"
agentName = "sisyphus";
}
}
if (agentName) {
const parsed = extractProviderModelFromErrorMessage(errorMessage)
const currentProvider = props?.providerID as string || parsed.providerID || "opencode"
let currentModel = props?.modelID as string || parsed.modelID || "claude-opus-4-6"
currentModel = normalizeFallbackModelID(currentModel)
const setFallback = setPendingModelFallback(
sessionID,
agentName,
currentProvider,
currentModel,
)
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {})
if (agentName) {
const parsed = extractProviderModelFromErrorMessage(errorMessage);
const currentProvider = (props?.providerID as string) || parsed.providerID || "opencode";
let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-6";
currentModel = normalizeFallbackModelID(currentModel);
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
if (
setFallback &&
shouldAutoRetrySession(sessionID) &&
!hooks.stopContinuationGuard?.isStopped(sessionID)
) {
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {});
}
}
}
} catch (err) {
const sessionID = props?.sessionID as string | undefined
log("[event] model-fallback error in session.error:", { sessionID, error: err })
const sessionID = props?.sessionID as string | undefined;
log("[event] model-fallback error in session.error:", { sessionID, error: err });
}
}
}
};
}

View File

@@ -0,0 +1,33 @@
const { describe, expect, test, spyOn } = require("bun:test")
const sessionState = require("../features/claude-code-session-state")
const { createToolExecuteBeforeHandler } = require("./tool-execute-before")
describe("createToolExecuteBeforeHandler session notification sessionID", () => {
test("uses main session fallback when input sessionID is empty", async () => {
const mainSessionID = "ses_main"
const getMainSessionIDSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(mainSessionID)
let capturedSessionID: string | undefined
const hooks = {
sessionNotification: async (input) => {
capturedSessionID = input.event.properties?.sessionID
},
}
const handler = createToolExecuteBeforeHandler({
ctx: { client: { session: { messages: async () => ({ data: [] }) } } },
hooks,
})
await handler(
{ tool: "question", sessionID: "", callID: "call_q" },
{ args: { questions: [{ question: "Continue?", options: [{ label: "Yes" }] }] } },
)
expect(getMainSessionIDSpy).toHaveBeenCalled()
expect(capturedSessionID).toBe(mainSessionID)
})
})
export {}

View File

@@ -31,6 +31,60 @@ describe("createToolExecuteBeforeHandler", () => {
await expect(run).resolves.toBeUndefined()
})
test("triggers session notification hook for question tools", async () => {
let called = false
const ctx = {
client: {
session: {
messages: async () => ({ data: [] }),
},
},
}
const hooks = {
sessionNotification: async (input: { event: { type: string; properties?: Record<string, unknown> } }) => {
called = true
expect(input.event.type).toBe("tool.execute.before")
expect(input.event.properties?.sessionID).toBe("ses_q")
expect(input.event.properties?.tool).toBe("question")
},
}
const handler = createToolExecuteBeforeHandler({ ctx, hooks })
const input = { tool: "question", sessionID: "ses_q", callID: "call_q" }
const output = { args: { questions: [{ question: "Proceed?", options: [{ label: "Yes" }] }] } as Record<string, unknown> }
await handler(input, output)
expect(called).toBe(true)
})
test("does not trigger session notification hook for non-question tools", async () => {
let called = false
const ctx = {
client: {
session: {
messages: async () => ({ data: [] }),
},
},
}
const hooks = {
sessionNotification: async () => {
called = true
},
}
const handler = createToolExecuteBeforeHandler({ ctx, hooks })
await handler(
{ tool: "bash", sessionID: "ses_b", callID: "call_b" },
{ args: { command: "pwd" } as Record<string, unknown> },
)
expect(called).toBe(false)
})
describe("task tool subagent_type normalization", () => {
const emptyHooks = {}

View File

@@ -30,6 +30,26 @@ export function createToolExecuteBeforeHandler(args: {
await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output)
await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output)
await hooks.atlasHook?.["tool.execute.before"]?.(input, output)
const normalizedToolName = input.tool.toLowerCase()
if (
normalizedToolName === "question"
|| normalizedToolName === "ask_user_question"
|| normalizedToolName === "askuserquestion"
) {
const sessionID = input.sessionID || getMainSessionID()
await hooks.sessionNotification?.({
event: {
type: "tool.execute.before",
properties: {
sessionID,
tool: input.tool,
args: output.args,
},
},
})
}
if (input.tool === "task") {
const argsObject = output.args
const category = typeof argsObject.category === "string" ? argsObject.category : undefined

View File

@@ -1,78 +1,129 @@
import { spawn } from "node:child_process"
import { getHomeDirectory } from "./home-directory"
import { findBashPath, findZshPath } from "./shell-path"
import { spawn } from "node:child_process";
import { getHomeDirectory } from "./home-directory";
import { findBashPath, findZshPath } from "./shell-path";
export interface CommandResult {
exitCode: number
stdout?: string
stderr?: string
exitCode: number;
stdout?: string;
stderr?: string;
}
const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
const SIGKILL_GRACE_MS = 5_000;
export interface ExecuteHookOptions {
forceZsh?: boolean
zshPath?: string
forceZsh?: boolean;
zshPath?: string;
/** Timeout in milliseconds. Process is killed after this. Default: 30000 */
timeoutMs?: number;
}
export async function executeHookCommand(
command: string,
stdin: string,
cwd: string,
options?: ExecuteHookOptions,
command: string,
stdin: string,
cwd: string,
options?: ExecuteHookOptions,
): Promise<CommandResult> {
const home = getHomeDirectory()
const home = getHomeDirectory();
const timeoutMs = options?.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
const expandedCommand = command
.replace(/^~(?=\/|$)/g, home)
.replace(/\s~(?=\/)/g, ` ${home}`)
.replace(/\$CLAUDE_PROJECT_DIR/g, cwd)
.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd)
const expandedCommand = command
.replace(/^~(?=\/|$)/g, home)
.replace(/\s~(?=\/)/g, ` ${home}`)
.replace(/\$CLAUDE_PROJECT_DIR/g, cwd)
.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd);
let finalCommand = expandedCommand
let finalCommand = expandedCommand;
if (options?.forceZsh) {
const zshPath = findZshPath(options.zshPath)
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
if (zshPath) {
finalCommand = `${zshPath} -lc '${escapedCommand}'`
} else {
const bashPath = findBashPath()
if (bashPath) {
finalCommand = `${bashPath} -lc '${escapedCommand}'`
}
}
}
if (options?.forceZsh) {
const zshPath = findZshPath(options.zshPath);
const escapedCommand = expandedCommand.replace(/'/g, "'\\''");
if (zshPath) {
finalCommand = `${zshPath} -lc '${escapedCommand}'`;
} else {
const bashPath = findBashPath();
if (bashPath) {
finalCommand = `${bashPath} -lc '${escapedCommand}'`;
}
}
}
return new Promise((resolve) => {
const proc = spawn(finalCommand, {
cwd,
shell: true,
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
})
return new Promise(resolve => {
let settled = false;
let killTimer: ReturnType<typeof setTimeout> | null = null;
let stdout = ""
let stderr = ""
const isWin32 = process.platform === "win32";
const proc = spawn(finalCommand, {
cwd,
shell: true,
detached: !isWin32,
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
});
proc.stdout?.on("data", (data) => {
stdout += data.toString()
})
let stdout = "";
let stderr = "";
proc.stderr?.on("data", (data) => {
stderr += data.toString()
})
proc.stdout?.on("data", (data: Buffer) => {
stdout += data.toString();
});
proc.stdin?.write(stdin)
proc.stdin?.end()
proc.stderr?.on("data", (data: Buffer) => {
stderr += data.toString();
});
proc.on("close", (code) => {
resolve({
exitCode: code ?? 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
})
})
proc.stdin?.on("error", () => {});
proc.stdin?.write(stdin);
proc.stdin?.end();
proc.on("error", (err) => {
resolve({ exitCode: 1, stderr: err.message })
})
})
const settle = (result: CommandResult) => {
if (settled) return;
settled = true;
if (killTimer) clearTimeout(killTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
resolve(result);
};
proc.on("close", code => {
settle({
exitCode: code ?? 1,
stdout: stdout.trim(),
stderr: stderr.trim(),
});
});
proc.on("error", err => {
settle({ exitCode: 1, stderr: err.message });
});
const killProcessGroup = (signal: NodeJS.Signals) => {
try {
if (!isWin32 && proc.pid) {
try {
process.kill(-proc.pid, signal);
} catch {
proc.kill(signal);
}
} else {
proc.kill(signal);
}
} catch {}
};
const timeoutTimer = setTimeout(() => {
if (settled) return;
// Kill entire process group to avoid orphaned children
killProcessGroup("SIGTERM");
killTimer = setTimeout(() => {
if (settled) return;
killProcessGroup("SIGKILL");
}, SIGKILL_GRACE_MS);
// Append timeout notice to stderr
stderr += `\nHook command timed out after ${timeoutMs}ms`;
}, timeoutMs);
// Don't let the timeout timer keep the process alive
if (timeoutTimer && typeof timeoutTimer === "object" && "unref" in timeoutTimer) {
timeoutTimer.unref();
}
});
}

View File

@@ -1,14 +1,18 @@
const store = new Map<string, Record<string, boolean>>()
const store = new Map<string, Record<string, boolean>>();
export function setSessionTools(sessionID: string, tools: Record<string, boolean>): void {
store.set(sessionID, { ...tools })
store.set(sessionID, { ...tools });
}
export function getSessionTools(sessionID: string): Record<string, boolean> | undefined {
const tools = store.get(sessionID)
return tools ? { ...tools } : undefined
const tools = store.get(sessionID);
return tools ? { ...tools } : undefined;
}
export function deleteSessionTools(sessionID: string): void {
store.delete(sessionID);
}
export function clearSessionTools(): void {
store.clear()
store.clear();
}

View File

@@ -7,9 +7,11 @@ import {
DEFAULT_MAX_DEPTH,
DEFAULT_MAX_OUTPUT_BYTES,
RG_FILES_FLAGS,
DEFAULT_RG_THREADS,
} from "./constants"
import type { GlobOptions, GlobResult, FileMatch } from "./types"
import { stat } from "node:fs/promises"
import { rgSemaphore } from "../shared/semaphore"
export interface ResolvedCli {
path: string
@@ -19,6 +21,7 @@ export interface ResolvedCli {
function buildRgArgs(options: GlobOptions): string[] {
const args: string[] = [
...RG_FILES_FLAGS,
`--threads=${Math.min(options.threads ?? DEFAULT_RG_THREADS, DEFAULT_RG_THREADS)}`,
`--max-depth=${Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)}`,
]
@@ -91,6 +94,18 @@ export { buildRgArgs, buildFindArgs, buildPowerShellCommand }
export async function runRgFiles(
options: GlobOptions,
resolvedCli?: ResolvedCli
): Promise<GlobResult> {
await rgSemaphore.acquire()
try {
return await runRgFilesInternal(options, resolvedCli)
} finally {
rgSemaphore.release()
}
}
async function runRgFilesInternal(
options: GlobOptions,
resolvedCli?: ResolvedCli
): Promise<GlobResult> {
const cli = resolvedCli ?? resolveGrepCli()
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)

View File

@@ -1,4 +1,4 @@
export { resolveGrepCli, resolveGrepCliWithAutoInstall, type GrepBackend } from "../grep/constants"
export { resolveGrepCli, resolveGrepCliWithAutoInstall, type GrepBackend, DEFAULT_RG_THREADS } from "../grep/constants"
export const DEFAULT_TIMEOUT_MS = 60_000
export const DEFAULT_LIMIT = 100

View File

@@ -19,4 +19,5 @@ export interface GlobOptions {
maxDepth?: number
timeout?: number
limit?: number
threads?: number // limit rg thread count
}

View File

@@ -8,14 +8,17 @@ import {
DEFAULT_MAX_COLUMNS,
DEFAULT_TIMEOUT_MS,
DEFAULT_MAX_OUTPUT_BYTES,
DEFAULT_RG_THREADS,
RG_SAFETY_FLAGS,
GREP_SAFETY_FLAGS,
} from "./constants"
import type { GrepOptions, GrepMatch, GrepResult, CountResult } from "./types"
import { rgSemaphore } from "../shared/semaphore"
function buildRgArgs(options: GrepOptions): string[] {
const args: string[] = [
...RG_SAFETY_FLAGS,
`--threads=${Math.min(options.threads ?? DEFAULT_RG_THREADS, DEFAULT_RG_THREADS)}`,
`--max-depth=${Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)}`,
`--max-filesize=${options.maxFilesize ?? DEFAULT_MAX_FILESIZE}`,
`--max-count=${Math.min(options.maxCount ?? DEFAULT_MAX_COUNT, DEFAULT_MAX_COUNT)}`,
@@ -51,6 +54,12 @@ function buildRgArgs(options: GrepOptions): string[] {
}
}
if (options.outputMode === "files_with_matches") {
args.push("--files-with-matches")
} else if (options.outputMode === "count") {
args.push("--count")
}
return args
}
@@ -86,7 +95,7 @@ function buildArgs(options: GrepOptions, backend: GrepBackend): string[] {
return backend === "rg" ? buildRgArgs(options) : buildGrepArgs(options)
}
function parseOutput(output: string): GrepMatch[] {
function parseOutput(output: string, filesOnly = false): GrepMatch[] {
if (!output.trim()) return []
const matches: GrepMatch[] = []
@@ -95,6 +104,16 @@ function parseOutput(output: string): GrepMatch[] {
for (const line of lines) {
if (!line.trim()) continue
if (filesOnly) {
// --files-with-matches outputs only file paths, one per line
matches.push({
file: line.trim(),
line: 0,
text: "",
})
continue
}
const match = line.match(/^(.+?):(\d+):(.*)$/)
if (match) {
matches.push({
@@ -130,6 +149,15 @@ function parseCountOutput(output: string): CountResult[] {
}
export async function runRg(options: GrepOptions): Promise<GrepResult> {
await rgSemaphore.acquire()
try {
return await runRgInternal(options)
} finally {
rgSemaphore.release()
}
}
async function runRgInternal(options: GrepOptions): Promise<GrepResult> {
const cli = resolveGrepCli()
const args = buildArgs(options, cli.backend)
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
@@ -173,14 +201,17 @@ export async function runRg(options: GrepOptions): Promise<GrepResult> {
}
}
const matches = parseOutput(outputToProcess)
const filesSearched = new Set(matches.map((m) => m.file)).size
const matches = parseOutput(outputToProcess, options.outputMode === "files_with_matches")
const limited = options.headLimit && options.headLimit > 0
? matches.slice(0, options.headLimit)
: matches
const filesSearched = new Set(limited.map((m) => m.file)).size
return {
matches,
totalMatches: matches.length,
matches: limited,
totalMatches: limited.length,
filesSearched,
truncated,
truncated: truncated || (options.headLimit ? matches.length > options.headLimit : false),
}
} catch (e) {
return {
@@ -194,6 +225,15 @@ export async function runRg(options: GrepOptions): Promise<GrepResult> {
}
export async function runRgCount(options: Omit<GrepOptions, "context">): Promise<CountResult[]> {
await rgSemaphore.acquire()
try {
return await runRgCountInternal(options)
} finally {
rgSemaphore.release()
}
}
async function runRgCountInternal(options: Omit<GrepOptions, "context">): Promise<CountResult[]> {
const cli = resolveGrepCli()
const args = buildArgs({ ...options, context: 0 }, cli.backend)

View File

@@ -113,8 +113,9 @@ export const DEFAULT_MAX_FILESIZE = "10M"
export const DEFAULT_MAX_COUNT = 500
export const DEFAULT_MAX_COLUMNS = 1000
export const DEFAULT_CONTEXT = 2
export const DEFAULT_TIMEOUT_MS = 300_000
export const DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024
export const DEFAULT_TIMEOUT_MS = 60_000
export const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024
export const DEFAULT_RG_THREADS = 4
export const RG_SAFETY_FLAGS = [
"--no-follow",

View File

@@ -0,0 +1,123 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { formatGrepResult } from "./result-formatter"
import type { GrepResult } from "./types"
describe("formatGrepResult", () => {
describe("#given grep result has error", () => {
describe("#when formatting result", () => {
test("#then returns error message", () => {
const result: GrepResult = {
matches: [],
totalMatches: 0,
filesSearched: 0,
truncated: false,
error: "ripgrep failed",
}
const formatted = formatGrepResult(result)
expect(formatted).toBe("Error: ripgrep failed")
})
})
})
describe("#given grep result has no matches", () => {
describe("#when formatting result", () => {
test("#then returns no matches message", () => {
const result: GrepResult = {
matches: [],
totalMatches: 0,
filesSearched: 0,
truncated: false,
}
const formatted = formatGrepResult(result)
expect(formatted).toBe("No matches found")
})
})
})
describe("#given grep result is files-with-matches mode", () => {
describe("#when formatting result", () => {
test("#then prints only file paths", () => {
const result: GrepResult = {
matches: [
{ file: "src/foo.ts", line: 0, text: "" },
{ file: "src/bar.ts", line: 0, text: "" },
{ file: "src/baz.ts", line: 0, text: "" },
],
totalMatches: 3,
filesSearched: 3,
truncated: false,
}
const formatted = formatGrepResult(result)
expect(formatted).toBe(
"Found 3 match(es) in 3 file(s)\n\n" +
"src/foo.ts\n\n" +
"src/bar.ts\n\n" +
"src/baz.ts\n",
)
})
})
})
describe("#given grep result is content mode", () => {
describe("#when formatting result", () => {
test("#then prints line numbers and content", () => {
const result: GrepResult = {
matches: [
{ file: "src/foo.ts", line: 10, text: " function hello() {" },
{ file: "src/foo.ts", line: 25, text: " function world() {" },
{ file: "src/bar.ts", line: 5, text: ' import { hello } from "./foo"' },
],
totalMatches: 3,
filesSearched: 2,
truncated: false,
}
const formatted = formatGrepResult(result)
expect(formatted).toBe(
"Found 3 match(es) in 2 file(s)\n\n" +
"src/foo.ts\n" +
" 10: function hello() {\n" +
" 25: function world() {\n\n" +
"src/bar.ts\n" +
' 5: import { hello } from "./foo"\n',
)
})
})
})
describe("#given grep result has mixed file-only and content matches", () => {
describe("#when formatting result", () => {
test("#then skips file-only placeholders and prints valid content matches", () => {
const result: GrepResult = {
matches: [
{ file: "src/foo.ts", line: 0, text: "" },
{ file: "src/foo.ts", line: 10, text: " function hello() {" },
{ file: "src/bar.ts", line: 0, text: "" },
],
totalMatches: 3,
filesSearched: 2,
truncated: false,
}
const formatted = formatGrepResult(result)
expect(formatted).toBe(
"Found 3 match(es) in 2 file(s)\n\n" +
"src/foo.ts\n" +
" 10: function hello() {\n\n" +
"src/bar.ts\n",
)
})
})
})
})

View File

@@ -10,6 +10,7 @@ export function formatGrepResult(result: GrepResult): string {
}
const lines: string[] = []
const isFilesOnlyMode = result.matches.every((match) => match.line === 0 && match.text.trim() === "")
lines.push(`Found ${result.totalMatches} match(es) in ${result.filesSearched} file(s)`)
if (result.truncated) {
@@ -26,8 +27,14 @@ export function formatGrepResult(result: GrepResult): string {
for (const [file, matches] of byFile) {
lines.push(file)
for (const match of matches) {
lines.push(` ${match.line}: ${match.text.trim()}`)
if (!isFilesOnlyMode) {
for (const match of matches) {
const trimmedText = match.text.trim()
if (match.line === 0 && trimmedText === "") {
continue
}
lines.push(` ${match.line}: ${trimmedText}`)
}
}
lines.push("")
}

View File

@@ -1,16 +1,16 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { runRg } from "./cli"
import { formatGrepResult } from "./result-formatter"
import { runRg, runRgCount } from "./cli"
import { formatGrepResult, formatCountResult } from "./result-formatter"
export function createGrepTools(ctx: PluginInput): Record<string, ToolDefinition> {
const grep: ToolDefinition = tool({
description:
"Fast content search tool with safety limits (60s timeout, 10MB output). " +
"Fast content search tool with safety limits (60s timeout, 256KB output). " +
"Searches file contents using regular expressions. " +
"Supports full regex syntax (eg. \"log.*Error\", \"function\\s+\\w+\", etc.). " +
"Filter files by pattern with the include parameter (eg. \"*.js\", \"*.{ts,tsx}\"). " +
"Returns file paths with matches sorted by modification time.",
"Output modes: \"content\" shows matching lines, \"files_with_matches\" shows only file paths (default), \"count\" shows match counts per file.",
args: {
pattern: tool.schema.string().describe("The regex pattern to search for in file contents"),
include: tool.schema
@@ -21,18 +21,42 @@ export function createGrepTools(ctx: PluginInput): Record<string, ToolDefinition
.string()
.optional()
.describe("The directory to search in. Defaults to the current working directory."),
output_mode: tool.schema
.enum(["content", "files_with_matches", "count"])
.optional()
.describe(
"Output mode: \"content\" shows matching lines, \"files_with_matches\" shows only file paths (default), \"count\" shows match counts per file."
),
head_limit: tool.schema
.number()
.optional()
.describe("Limit output to first N entries. 0 or omitted means no limit."),
},
execute: async (args) => {
try {
const globs = args.include ? [args.include] : undefined
const searchPath = args.path ?? ctx.directory
const paths = [searchPath]
const outputMode = args.output_mode ?? "files_with_matches"
const headLimit = args.head_limit ?? 0
if (outputMode === "count") {
const results = await runRgCount({
pattern: args.pattern,
paths,
globs,
})
const limited = headLimit > 0 ? results.slice(0, headLimit) : results
return formatCountResult(limited)
}
const result = await runRg({
pattern: args.pattern,
paths,
globs,
context: 0,
outputMode,
headLimit,
})
return formatGrepResult(result)

View File

@@ -31,6 +31,9 @@ export interface GrepOptions {
noIgnore?: boolean
fileType?: string[]
timeout?: number
threads?: number
outputMode?: "content" | "files_with_matches" | "count"
headLimit?: number
}
export interface CountResult {

View File

@@ -1,45 +1,71 @@
type ManagedClientForCleanup = {
client: {
stop: () => Promise<void>
}
}
stop: () => Promise<void>;
};
};
type ProcessCleanupOptions = {
getClients: () => IterableIterator<[string, ManagedClientForCleanup]>
clearClients: () => void
clearCleanupInterval: () => void
}
getClients: () => IterableIterator<[string, ManagedClientForCleanup]>;
clearClients: () => void;
clearCleanupInterval: () => void;
};
type RegisteredHandler = {
event: string;
listener: (...args: unknown[]) => void;
};
export type LspProcessCleanupHandle = {
unregister: () => void;
};
export function registerLspManagerProcessCleanup(options: ProcessCleanupOptions): LspProcessCleanupHandle {
const handlers: RegisteredHandler[] = [];
export function registerLspManagerProcessCleanup(options: ProcessCleanupOptions): void {
// Synchronous cleanup for 'exit' event (cannot await)
const syncCleanup = () => {
for (const [, managed] of options.getClients()) {
try {
// Fire-and-forget during sync exit - process is terminating
void managed.client.stop().catch(() => {})
void managed.client.stop().catch(() => {});
} catch {}
}
options.clearClients()
options.clearCleanupInterval()
}
options.clearClients();
options.clearCleanupInterval();
};
// Async cleanup for signal handlers - properly await all stops
const asyncCleanup = async () => {
const stopPromises: Promise<void>[] = []
const stopPromises: Promise<void>[] = [];
for (const [, managed] of options.getClients()) {
stopPromises.push(managed.client.stop().catch(() => {}))
stopPromises.push(managed.client.stop().catch(() => {}));
}
await Promise.allSettled(stopPromises)
options.clearClients()
options.clearCleanupInterval()
}
await Promise.allSettled(stopPromises);
options.clearClients();
options.clearCleanupInterval();
};
process.on("exit", syncCleanup)
const registerHandler = (event: string, listener: (...args: unknown[]) => void) => {
handlers.push({ event, listener });
process.on(event, listener);
};
registerHandler("exit", syncCleanup);
// Don't call process.exit() here; other handlers (background-agent manager) handle final exit.
process.on("SIGINT", () => void asyncCleanup().catch(() => {}))
process.on("SIGTERM", () => void asyncCleanup().catch(() => {}))
const signalCleanup = () => void asyncCleanup().catch(() => {});
registerHandler("SIGINT", signalCleanup);
registerHandler("SIGTERM", signalCleanup);
if (process.platform === "win32") {
process.on("SIGBREAK", () => void asyncCleanup().catch(() => {}))
registerHandler("SIGBREAK", signalCleanup);
}
return {
unregister: () => {
for (const { event, listener } of handlers) {
process.off(event, listener);
}
handlers.length = 0;
},
};
}

View File

@@ -1,73 +1,74 @@
import type { ResolvedServer } from "./types"
import { registerLspManagerProcessCleanup } from "./lsp-manager-process-cleanup"
import { cleanupTempDirectoryLspClients } from "./lsp-manager-temp-directory-cleanup"
import { LSPClient } from "./lsp-client"
import { LSPClient } from "./lsp-client";
import { registerLspManagerProcessCleanup, type LspProcessCleanupHandle } from "./lsp-manager-process-cleanup";
import { cleanupTempDirectoryLspClients } from "./lsp-manager-temp-directory-cleanup";
import type { ResolvedServer } from "./types";
interface ManagedClient {
client: LSPClient
lastUsedAt: number
refCount: number
initPromise?: Promise<void>
isInitializing: boolean
initializingSince?: number
client: LSPClient;
lastUsedAt: number;
refCount: number;
initPromise?: Promise<void>;
isInitializing: boolean;
initializingSince?: number;
}
class LSPServerManager {
private static instance: LSPServerManager
private clients = new Map<string, ManagedClient>()
private cleanupInterval: ReturnType<typeof setInterval> | null = null
private readonly IDLE_TIMEOUT = 5 * 60 * 1000
private readonly INIT_TIMEOUT = 60 * 1000
private static instance: LSPServerManager;
private clients = new Map<string, ManagedClient>();
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
private readonly IDLE_TIMEOUT = 5 * 60 * 1000;
private readonly INIT_TIMEOUT = 60 * 1000;
private cleanupHandle: LspProcessCleanupHandle | null = null;
private constructor() {
this.startCleanupTimer()
this.registerProcessCleanup()
this.startCleanupTimer();
this.registerProcessCleanup();
}
private registerProcessCleanup(): void {
registerLspManagerProcessCleanup({
this.cleanupHandle = registerLspManagerProcessCleanup({
getClients: () => this.clients.entries(),
clearClients: () => {
this.clients.clear()
this.clients.clear();
},
clearCleanupInterval: () => {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
},
})
});
}
static getInstance(): LSPServerManager {
if (!LSPServerManager.instance) {
LSPServerManager.instance = new LSPServerManager()
LSPServerManager.instance = new LSPServerManager();
}
return LSPServerManager.instance
return LSPServerManager.instance;
}
private getKey(root: string, serverId: string): string {
return `${root}::${serverId}`
return `${root}::${serverId}`;
}
private startCleanupTimer(): void {
if (this.cleanupInterval) return
if (this.cleanupInterval) return;
this.cleanupInterval = setInterval(() => {
this.cleanupIdleClients()
}, 60000)
this.cleanupIdleClients();
}, 60000);
}
private cleanupIdleClients(): void {
const now = Date.now()
const now = Date.now();
for (const [key, managed] of this.clients) {
if (managed.refCount === 0 && now - managed.lastUsedAt > this.IDLE_TIMEOUT) {
managed.client.stop()
this.clients.delete(key)
managed.client.stop();
this.clients.delete(key);
}
}
}
async getClient(root: string, server: ResolvedServer): Promise<LSPClient> {
const key = this.getKey(root, server.id)
let managed = this.clients.get(key)
const key = this.getKey(root, server.id);
let managed = this.clients.get(key);
if (managed) {
const now = Date.now()
const now = Date.now();
if (
managed.isInitializing &&
managed.initializingSince !== undefined &&
@@ -75,45 +76,45 @@ class LSPServerManager {
) {
// Stale init can permanently block subsequent calls (e.g., LSP process hang)
try {
await managed.client.stop()
await managed.client.stop();
} catch {}
this.clients.delete(key)
managed = undefined
this.clients.delete(key);
managed = undefined;
}
}
if (managed) {
if (managed.initPromise) {
try {
await managed.initPromise
await managed.initPromise;
} catch {
// Failed init should not keep the key blocked forever.
try {
await managed.client.stop()
await managed.client.stop();
} catch {}
this.clients.delete(key)
managed = undefined
this.clients.delete(key);
managed = undefined;
}
}
if (managed) {
if (managed.client.isAlive()) {
managed.refCount++
managed.lastUsedAt = Date.now()
return managed.client
managed.refCount++;
managed.lastUsedAt = Date.now();
return managed.client;
}
try {
await managed.client.stop()
await managed.client.stop();
} catch {}
this.clients.delete(key)
this.clients.delete(key);
}
}
const client = new LSPClient(root, server)
const client = new LSPClient(root, server);
const initPromise = (async () => {
await client.start()
await client.initialize()
})()
const initStartedAt = Date.now()
await client.start();
await client.initialize();
})();
const initStartedAt = Date.now();
this.clients.set(key, {
client,
lastUsedAt: initStartedAt,
@@ -121,37 +122,37 @@ class LSPServerManager {
initPromise,
isInitializing: true,
initializingSince: initStartedAt,
})
});
try {
await initPromise
await initPromise;
} catch (error) {
this.clients.delete(key)
this.clients.delete(key);
try {
await client.stop()
await client.stop();
} catch {}
throw error
throw error;
}
const m = this.clients.get(key)
const m = this.clients.get(key);
if (m) {
m.initPromise = undefined
m.isInitializing = false
m.initializingSince = undefined
m.initPromise = undefined;
m.isInitializing = false;
m.initializingSince = undefined;
}
return client
return client;
}
warmupClient(root: string, server: ResolvedServer): void {
const key = this.getKey(root, server.id)
if (this.clients.has(key)) return
const client = new LSPClient(root, server)
const key = this.getKey(root, server.id);
if (this.clients.has(key)) return;
const client = new LSPClient(root, server);
const initPromise = (async () => {
await client.start()
await client.initialize()
})()
await client.start();
await client.initialize();
})();
const initStartedAt = Date.now()
const initStartedAt = Date.now();
this.clients.set(key, {
client,
lastUsedAt: initStartedAt,
@@ -159,53 +160,55 @@ class LSPServerManager {
initPromise,
isInitializing: true,
initializingSince: initStartedAt,
})
});
initPromise
.then(() => {
const m = this.clients.get(key)
const m = this.clients.get(key);
if (m) {
m.initPromise = undefined
m.isInitializing = false
m.initializingSince = undefined
m.initPromise = undefined;
m.isInitializing = false;
m.initializingSince = undefined;
}
})
.catch(() => {
// Warmup failures must not permanently block future initialization.
this.clients.delete(key)
void client.stop().catch(() => {})
})
this.clients.delete(key);
void client.stop().catch(() => {});
});
}
releaseClient(root: string, serverId: string): void {
const key = this.getKey(root, serverId)
const managed = this.clients.get(key)
const key = this.getKey(root, serverId);
const managed = this.clients.get(key);
if (managed && managed.refCount > 0) {
managed.refCount--
managed.lastUsedAt = Date.now()
managed.refCount--;
managed.lastUsedAt = Date.now();
}
}
isServerInitializing(root: string, serverId: string): boolean {
const key = this.getKey(root, serverId)
const managed = this.clients.get(key)
return managed?.isInitializing ?? false
const key = this.getKey(root, serverId);
const managed = this.clients.get(key);
return managed?.isInitializing ?? false;
}
async stopAll(): Promise<void> {
this.cleanupHandle?.unregister();
this.cleanupHandle = null;
for (const [, managed] of this.clients) {
await managed.client.stop()
await managed.client.stop();
}
this.clients.clear()
this.clients.clear();
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
}
async cleanupTempDirectoryClients(): Promise<void> {
await cleanupTempDirectoryLspClients(this.clients)
await cleanupTempDirectoryLspClients(this.clients);
}
}
export const lspManager = LSPServerManager.getInstance()
export const lspManager = LSPServerManager.getInstance();

View File

@@ -0,0 +1,32 @@
/**
* Simple counting semaphore to limit concurrent process execution.
* Used to prevent multiple ripgrep processes from saturating CPU.
*/
export class Semaphore {
private queue: (() => void)[] = []
private running = 0
constructor(private readonly max: number) {}
async acquire(): Promise<void> {
if (this.running < this.max) {
this.running++
return
}
return new Promise<void>((resolve) => {
this.queue.push(() => {
this.running++
resolve()
})
})
}
release(): void {
this.running--
const next = this.queue.shift()
if (next) next()
}
}
/** Global semaphore limiting concurrent ripgrep processes to 2 */
export const rgSemaphore = new Semaphore(2)