Compare commits

...

6 Commits

Author SHA1 Message Date
github-actions[bot]
2bdab59f22 release: v2.7.2 2025-12-29 07:24:54 +00:00
YeonGyu-Kim
59507500ea fix(todo-continuation-enforcer): allow background task sessions to receive todo-continuation
Background task sessions registered in subagentSessions were not receiving
todo-continuation prompts, causing a deadlock: background tasks waited for
continuation that never came.

Changes:
- Allow both main session and background task sessions to receive continuation
- Add test for background task session continuation behavior
- Cleanup subagentSessions in test setup/teardown

This fixes the deadlock introduced in commit 116a90d which added todo waiting
logic to background-agent/manager.ts.

🤖 Generated with assistance of OhMyOpenCode
2025-12-29 16:21:49 +09:00
Sisyphus
3a08dcaeb1 fix: detect opencode-desktop binary in installer (#313) 2025-12-29 10:34:11 +09:00
adam2am
c01b21d0f8 fix(lsp): improve isServerInstalled for custom server configs (#282) 2025-12-29 10:22:38 +09:00
Sisyphus
6dd98254be fix: improve glob tool Windows compatibility and rg resolution (#309) 2025-12-29 10:10:22 +09:00
github-actions[bot]
55a3a6c9eb @Fguedes90 has signed the CLA in code-yeongyu/oh-my-opencode#319 2025-12-28 23:34:29 +00:00
10 changed files with 149 additions and 40 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "2.7.1",
"version": "2.7.2",
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -71,6 +71,14 @@
"created_at": "2025-12-28T09:24:03Z",
"repoId": 1108837393,
"pullRequestNo": 306
},
{
"name": "Fguedes90",
"id": 13650239,
"comment_id": 3695136375,
"created_at": "2025-12-28T23:34:19Z",
"repoId": 1108837393,
"pullRequestNo": 319
}
]
}

View File

@@ -10,6 +10,8 @@ const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
const OPENCODE_PACKAGE_JSON = join(OPENCODE_CONFIG_DIR, "package.json")
const OMO_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json")
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
const CHATGPT_HOTFIX_REPO = "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
export async function fetchLatestVersion(packageName: string): Promise<string | null> {
@@ -204,31 +206,38 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult
}
}
export async function isOpenCodeInstalled(): Promise<boolean> {
try {
const proc = Bun.spawn(["opencode", "--version"], {
stdout: "pipe",
stderr: "pipe",
})
await proc.exited
return proc.exitCode === 0
} catch {
return false
interface OpenCodeBinaryResult {
binary: string
version: string
}
async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | null> {
for (const binary of OPENCODE_BINARIES) {
try {
const proc = Bun.spawn([binary, "--version"], {
stdout: "pipe",
stderr: "pipe",
})
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
return { binary, version: output.trim() }
}
} catch {
continue
}
}
return null
}
export async function isOpenCodeInstalled(): Promise<boolean> {
const result = await findOpenCodeBinaryWithVersion()
return result !== null
}
export async function getOpenCodeVersion(): Promise<string | null> {
try {
const proc = Bun.spawn(["opencode", "--version"], {
stdout: "pipe",
stderr: "pipe",
})
const output = await new Response(proc.stdout).text()
await proc.exited
return proc.exitCode === 0 ? output.trim() : null
} catch {
return null
}
const result = await findOpenCodeBinaryWithVersion()
return result?.version ?? null
}
export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMergeResult> {

View File

@@ -1,7 +1,7 @@
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
import { setMainSession } from "../features/claude-code-session-state"
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
import type { BackgroundManager } from "../features/background-agent"
describe("todo-continuation-enforcer", () => {
@@ -51,10 +51,12 @@ describe("todo-continuation-enforcer", () => {
promptCalls = []
toastCalls = []
setMainSession(undefined)
subagentSessions.clear()
})
afterEach(() => {
setMainSession(undefined)
subagentSessions.clear()
})
test("should inject continuation when idle with incomplete todos", async () => {
@@ -143,6 +145,25 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls).toHaveLength(0)
})
test("should inject for background task session (subagent)", async () => {
// #given - main session set, background task session registered
setMainSession("main-session")
const bgTaskSession = "bg-task-session"
subagentSessions.add(bgTaskSession)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - background task session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID: bgTaskSession } },
})
// #then - continuation injected for background task session
await new Promise(r => setTimeout(r, 2500))
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe(bgTaskSession)
})
test("should skip injection after recent error", async () => {
// #given - session that just had an error
const sessionID = "main-error"

View File

@@ -1,7 +1,7 @@
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { getMainSessionID } from "../features/claude-code-session-state"
import { getMainSessionID, subagentSessions } from "../features/claude-code-session-state"
import {
findNearestMessageWithFields,
MESSAGE_STORAGE,
@@ -265,8 +265,11 @@ export function createTodoContinuationEnforcer(
log(`[${HOOK_NAME}] session.idle`, { sessionID })
const mainSessionID = getMainSessionID()
if (mainSessionID && sessionID !== mainSessionID) {
log(`[${HOOK_NAME}] Skipped: not main session`, { sessionID })
const isMainSession = sessionID === mainSessionID
const isBackgroundTaskSession = subagentSessions.has(sessionID)
if (mainSessionID && !isMainSession && !isBackgroundTaskSession) {
log(`[${HOOK_NAME}] Skipped: not main or background task session`, { sessionID })
return
}

View File

@@ -1,6 +1,7 @@
import { spawn } from "bun"
import {
resolveGrepCli,
type GrepBackend,
DEFAULT_TIMEOUT_MS,
DEFAULT_LIMIT,
DEFAULT_MAX_DEPTH,
@@ -10,6 +11,11 @@ import {
import type { GlobOptions, GlobResult, FileMatch } from "./types"
import { stat } from "node:fs/promises"
export interface ResolvedCli {
path: string
backend: GrepBackend
}
function buildRgArgs(options: GlobOptions): string[] {
const args: string[] = [
...RG_FILES_FLAGS,
@@ -40,6 +46,25 @@ function buildFindArgs(options: GlobOptions): string[] {
return args
}
function buildPowerShellCommand(options: GlobOptions): string[] {
const maxDepth = Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)
const paths = options.paths?.length ? options.paths : ["."]
const searchPath = paths[0] || "."
const escapedPath = searchPath.replace(/'/g, "''")
const escapedPattern = options.pattern.replace(/'/g, "''")
let psCommand = `Get-ChildItem -Path '${escapedPath}' -File -Recurse -Depth ${maxDepth - 1} -Filter '${escapedPattern}'`
if (options.hidden) {
psCommand += " -Force"
}
psCommand += " -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName"
return ["powershell", "-NoProfile", "-Command", psCommand]
}
async function getFileMtime(filePath: string): Promise<number> {
try {
const stats = await stat(filePath)
@@ -49,25 +74,40 @@ async function getFileMtime(filePath: string): Promise<number> {
}
}
export async function runRgFiles(options: GlobOptions): Promise<GlobResult> {
const cli = resolveGrepCli()
export async function runRgFiles(
options: GlobOptions,
resolvedCli?: ResolvedCli
): Promise<GlobResult> {
const cli = resolvedCli ?? resolveGrepCli()
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
const limit = Math.min(options.limit ?? DEFAULT_LIMIT, DEFAULT_LIMIT)
const isRg = cli.backend === "rg"
const args = isRg ? buildRgArgs(options) : buildFindArgs(options)
const isWindows = process.platform === "win32"
let command: string[]
let cwd: string | undefined
const paths = options.paths?.length ? options.paths : ["."]
if (isRg) {
const args = buildRgArgs(options)
const paths = options.paths?.length ? options.paths : ["."]
args.push(...paths)
command = [cli.path, ...args]
cwd = undefined
} else if (isWindows) {
command = buildPowerShellCommand(options)
cwd = undefined
} else {
const args = buildFindArgs(options)
const paths = options.paths?.length ? options.paths : ["."]
cwd = paths[0] || "."
command = [cli.path, ...args]
}
const cwd = paths[0] || "."
const proc = spawn([cli.path, ...args], {
const proc = spawn(command, {
stdout: "pipe",
stderr: "pipe",
cwd: isRg ? undefined : cwd,
cwd,
})
const timeoutPromise = new Promise<never>((_, reject) => {
@@ -106,7 +146,15 @@ export async function runRgFiles(options: GlobOptions): Promise<GlobResult> {
break
}
const filePath = isRg ? line : `${cwd}/${line}`
let filePath: string
if (isRg) {
filePath = line
} else if (isWindows) {
filePath = line.trim()
} else {
filePath = `${cwd}/${line}`
}
const mtime = await getFileMtime(filePath)
files.push({ path: filePath, mtime })
}

View File

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

View File

@@ -1,5 +1,6 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { runRgFiles } from "./cli"
import { resolveGrepCliWithAutoInstall } from "./constants"
import { formatGlobResult } from "./utils"
export const glob: ToolDefinition = tool({
@@ -21,12 +22,16 @@ export const glob: ToolDefinition = tool({
},
execute: async (args) => {
try {
const cli = await resolveGrepCliWithAutoInstall()
const paths = args.path ? [args.path] : undefined
const result = await runRgFiles({
pattern: args.pattern,
paths,
})
const result = await runRgFiles(
{
pattern: args.pattern,
paths,
},
cli
)
return formatGlobResult(result)
} catch (e) {

View File

@@ -2,6 +2,7 @@ import { existsSync } from "node:fs"
import { join, dirname } from "node:path"
import { spawnSync } from "node:child_process"
import { getInstalledRipgrepPath, downloadAndInstallRipgrep } from "./downloader"
import { getDataDir } from "../../shared/data-path"
export type GrepBackend = "rg" | "grep"
@@ -36,6 +37,9 @@ function getOpenCodeBundledRg(): string | null {
const rgName = isWindows ? "rg.exe" : "rg"
const candidates = [
// OpenCode XDG data path (highest priority - where OpenCode installs rg)
join(getDataDir(), "opencode", "bin", rgName),
// Legacy paths relative to execPath
join(execDir, rgName),
join(execDir, "bin", rgName),
join(execDir, "..", "bin", rgName),

View File

@@ -163,6 +163,12 @@ export function isServerInstalled(command: string[]): boolean {
if (command.length === 0) return false
const cmd = command[0]
// Support absolute paths (e.g., C:\Users\...\server.exe or /usr/local/bin/server)
if (cmd.includes("/") || cmd.includes("\\")) {
if (existsSync(cmd)) return true
}
const isWindows = process.platform === "win32"
const ext = isWindows ? ".exe" : ""
@@ -192,6 +198,11 @@ export function isServerInstalled(command: string[]): boolean {
}
}
// Runtime wrappers (bun/node) are always available in oh-my-opencode context
if (cmd === "bun" || cmd === "node") {
return true
}
return false
}