Compare commits

...

9 Commits

Author SHA1 Message Date
github-actions[bot]
2920d5fe65 release: v2.0.4 2025-12-15 00:06:49 +00:00
YeonGyu-Kim
7fd52e27ce refactor(non-interactive-env): use args.env instead of command prepending
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-15 08:54:33 +09:00
YeonGyu-Kim
08481c046f refactor(non-interactive-env): remove regex-based TUI blocking
Keep only environment variable configuration and stdin redirection.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-15 08:49:19 +09:00
YeonGyu-Kim
192e8adf18 refactor(hooks): rename interactive-bash-blocker to non-interactive-env
- Replace regex-based command blocking with environment configuration
- Add cross-platform null device support (NUL for Windows, /dev/null for Unix)
- Wrap all bash commands with non-interactive environment variables
- Only block TUI programs that require full PTY
- Update schema, README docs, and all imports/exports

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-15 08:49:19 +09:00
Junho Yeo
5dd4d97c94 fix(auto-update-checker): resolve unknown version display and improve JSONC parsing (#54) 2025-12-15 08:39:21 +09:00
YeonGyu-Kim
b1abb7999b refactor(interactive-bash-blocker): replace regex blocking with environment configuration
Instead of blocking commands via regex pattern matching (which caused false
positives like 'startup', 'support'), now wraps all bash commands with:
- CI=true
- DEBIAN_FRONTEND=noninteractive
- GIT_TERMINAL_PROMPT=0
- stdin redirected to /dev/null

TUI programs (text editors, system monitors, etc.) are still blocked as they
require full PTY. Other interactive commands now fail naturally when stdin
is unavailable.

Closes #55 via alternative approach.
2025-12-15 08:26:16 +09:00
YeonGyu-Kim
8618d57d95 add missing schema components 2025-12-14 22:34:55 +09:00
YeonGyu-Kim
4b6b725f13 feat(hooks): Add interactive-bash-blocker hook
- Prevent interactive bash commands from being executed automatically
- Block commands in tool.execute.before hook
- Register in schema and main plugin initialization
2025-12-14 22:27:19 +09:00
YeonGyu-Kim
1aaa6e6ba2 fix(session-recovery): Add placeholder message for thinking-only messages
- Add findMessagesWithThinkingOnly() to detect orphan thinking messages
- Inject [user interrupted] placeholder for thinking-only messages
- Expand index offset handling from 2 to 3 attempts for better error recovery
- Use constant PLACEHOLDER_TEXT for consistency across recovery functions
2025-12-14 22:26:58 +09:00
14 changed files with 1223 additions and 192 deletions

View File

@@ -608,7 +608,7 @@ OmO를 비활성화하고 원래 build/plan 에이전트를 복원하려면:
}
```
사용 가능한 훅: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`
사용 가능한 훅: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`
### MCPs

View File

@@ -609,7 +609,7 @@ Disable specific built-in hooks via `disabled_hooks` in `~/.config/opencode/oh-m
}
```
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`
### MCPs

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -60,6 +60,7 @@ export const HookNameSchema = z.enum([
"startup-toast",
"keyword-detector",
"agent-usage-reminder",
"non-interactive-env",
])
export const AgentOverrideConfigSchema = z.object({

View File

@@ -8,6 +8,7 @@ import {
NPM_FETCH_TIMEOUT,
INSTALLED_PACKAGE_JSON,
USER_OPENCODE_CONFIG,
USER_OPENCODE_CONFIG_JSONC,
} from "./constants"
import { log } from "../../shared/logger"
@@ -16,13 +17,22 @@ export function isLocalDevMode(directory: string): boolean {
}
function stripJsonComments(json: string): string {
return json.replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1")
return json
.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => (g ? "" : m))
.replace(/,(\s*[}\]])/g, "$1")
}
function getConfigPaths(directory: string): string[] {
return [
path.join(directory, ".opencode", "opencode.json"),
path.join(directory, ".opencode", "opencode.jsonc"),
USER_OPENCODE_CONFIG,
USER_OPENCODE_CONFIG_JSONC,
]
}
export function getLocalDevPath(directory: string): string | null {
const projectConfig = path.join(directory, ".opencode", "opencode.json")
for (const configPath of [projectConfig, USER_OPENCODE_CONFIG]) {
for (const configPath of getConfigPaths(directory)) {
try {
if (!fs.existsSync(configPath)) continue
const content = fs.readFileSync(configPath, "utf-8")
@@ -31,7 +41,11 @@ export function getLocalDevPath(directory: string): string | null {
for (const entry of plugins) {
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
return entry.replace("file://", "")
try {
return fileURLToPath(entry)
} catch {
return entry.replace("file://", "")
}
}
}
} catch {
@@ -86,9 +100,7 @@ export interface PluginEntryInfo {
}
export function findPluginEntry(directory: string): PluginEntryInfo | null {
const projectConfig = path.join(directory, ".opencode", "opencode.json")
for (const configPath of [projectConfig, USER_OPENCODE_CONFIG]) {
for (const configPath of getConfigPaths(directory)) {
try {
if (!fs.existsSync(configPath)) continue
const content = fs.readFileSync(configPath, "utf-8")
@@ -170,7 +182,6 @@ export async function checkForUpdate(directory: string): Promise<UpdateCheckResu
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false }
}
// Respect version pinning
if (pluginInfo.isPinned) {
log(`[auto-update-checker] Version pinned to ${pluginInfo.pinnedVersion}, skipping update check`)
return { needsUpdate: false, currentVersion: pluginInfo.pinnedVersion, latestVersion: null, isLocalDev: false, isPinned: true }
@@ -190,6 +201,5 @@ export async function checkForUpdate(directory: string): Promise<UpdateCheckResu
const needsUpdate = currentVersion !== latestVersion
log(`[auto-update-checker] Current: ${currentVersion}, Latest: ${latestVersion}, NeedsUpdate: ${needsUpdate}`)
return { needsUpdate, currentVersion, latestVersion, isLocalDev: false, isPinned: false }
}

View File

@@ -38,3 +38,4 @@ function getUserConfigDir(): string {
export const USER_CONFIG_DIR = getUserConfigDir()
export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode", "opencode.json")
export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode", "opencode.jsonc")

View File

@@ -17,3 +17,4 @@ export { createAutoUpdateCheckerHook } from "./auto-update-checker";
export { createAgentUsageReminderHook } from "./agent-usage-reminder";
export { createKeywordDetectorHook } from "./keyword-detector";
export { createNonInteractiveEnvHook } from "./non-interactive-env";

View File

@@ -0,0 +1,9 @@
export const HOOK_NAME = "non-interactive-env"
export const NON_INTERACTIVE_ENV: Record<string, string> = {
CI: "true",
DEBIAN_FRONTEND: "noninteractive",
GIT_TERMINAL_PROMPT: "0",
GCM_INTERACTIVE: "never",
HOMEBREW_NO_AUTO_UPDATE: "1",
}

View File

@@ -0,0 +1,34 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { HOOK_NAME, NON_INTERACTIVE_ENV } from "./constants"
import { log } from "../../shared"
export * from "./constants"
export * from "./types"
export function createNonInteractiveEnvHook(_ctx: PluginInput) {
return {
"tool.execute.before": async (
input: { tool: string; sessionID: string; callID: string },
output: { args: Record<string, unknown> }
): Promise<void> => {
if (input.tool.toLowerCase() !== "bash") {
return
}
const command = output.args.command as string | undefined
if (!command) {
return
}
output.args.env = {
...(output.args.env as Record<string, string> | undefined),
...NON_INTERACTIVE_ENV,
}
log(`[${HOOK_NAME}] Set non-interactive environment variables`, {
sessionID: input.sessionID,
env: NON_INTERACTIVE_ENV,
})
},
}
}

View File

@@ -0,0 +1,3 @@
export interface NonInteractiveEnvConfig {
disabled?: boolean
}

View File

@@ -6,6 +6,7 @@ import {
findMessageByIndexNeedingThinking,
findMessagesWithOrphanThinking,
findMessagesWithThinkingBlocks,
findMessagesWithThinkingOnly,
injectTextPart,
prependThinkingPart,
stripThinkingParts,
@@ -177,6 +178,8 @@ async function recoverThinkingDisabledViolation(
return anySuccess
}
const PLACEHOLDER_TEXT = "[user interrupted]"
async function recoverEmptyContentMessage(
_client: Client,
sessionID: string,
@@ -187,23 +190,28 @@ async function recoverEmptyContentMessage(
const targetIndex = extractMessageIndex(error)
const failedID = failedAssistantMsg.info?.id
const thinkingOnlyIDs = findMessagesWithThinkingOnly(sessionID)
for (const messageID of thinkingOnlyIDs) {
injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)
}
if (targetIndex !== null) {
const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex)
if (targetMessageID) {
return injectTextPart(sessionID, targetMessageID, "(interrupted)")
return injectTextPart(sessionID, targetMessageID, PLACEHOLDER_TEXT)
}
}
if (failedID) {
if (injectTextPart(sessionID, failedID, "(interrupted)")) {
if (injectTextPart(sessionID, failedID, PLACEHOLDER_TEXT)) {
return true
}
}
const emptyMessageIDs = findEmptyMessages(sessionID)
let anySuccess = false
let anySuccess = thinkingOnlyIDs.length > 0
for (const messageID of emptyMessageIDs) {
if (injectTextPart(sessionID, messageID, "(interrupted)")) {
if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
}

View File

@@ -133,20 +133,15 @@ export function findEmptyMessages(sessionID: string): string[] {
export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null {
const messages = readMessages(sessionID)
// Try multiple indices to handle system message offset
// API includes system message at index 0, storage may not
const indicesToTry = [targetIndex, targetIndex - 1]
// API index may differ from storage index due to system messages
const indicesToTry = [targetIndex, targetIndex - 1, targetIndex - 2]
for (const idx of indicesToTry) {
if (idx < 0 || idx >= messages.length) continue
const targetMsg = messages[idx]
// NOTE: Do NOT skip last assistant message here
// If API returned an error, this message is NOT the final assistant message
// (the API only allows empty content for the ACTUAL final assistant message)
if (!messageHasContent(targetMsg.id)) {
return targetMsg.id
}
@@ -177,6 +172,28 @@ export function findMessagesWithThinkingBlocks(sessionID: string): string[] {
return result
}
export function findMessagesWithThinkingOnly(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (const msg of messages) {
if (msg.role !== "assistant") continue
const parts = readParts(msg.id)
if (parts.length === 0) continue
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
const hasTextContent = parts.some(hasContent)
// Has thinking but no text content = orphan thinking
if (hasThinking && !hasTextContent) {
result.push(msg.id)
}
}
return result
}
export function findMessagesWithOrphanThinking(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []

View File

@@ -18,6 +18,7 @@ import {
createAutoUpdateCheckerHook,
createKeywordDetectorHook,
createAgentUsageReminderHook,
createNonInteractiveEnvHook,
} from "./hooks";
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
import {
@@ -238,6 +239,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const agentUsageReminder = isHookEnabled("agent-usage-reminder")
? createAgentUsageReminderHook(ctx)
: null;
const nonInteractiveEnv = isHookEnabled("non-interactive-env")
? createNonInteractiveEnvHook(ctx)
: null;
updateTerminalTitle({ sessionId: "main" });
@@ -479,6 +483,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
"tool.execute.before": async (input, output) => {
await claudeCodeHooks["tool.execute.before"](input, output);
await nonInteractiveEnv?.["tool.execute.before"](input, output);
await commentChecker?.["tool.execute.before"](input, output);
if (input.sessionID === getMainSessionID()) {