Compare commits

..

17 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
github-actions[bot]
7cb8210e65 release: v2.0.3 2025-12-14 13:22:43 +00:00
YeonGyu-Kim
7e4b633bbd feat(agents): add OmO and OmO-Plan as primary agents, demote build/plan
- OmO: Primary orchestrator (Claude Opus 4.5)
- OmO-Plan: Inherits ALL settings from OpenCode's plan agent at runtime
  - description appended with '(OhMyOpenCode version)'
  - Configurable via oh-my-opencode.json agents.OmO-Plan
- build/plan: Demoted to subagent when OmO enabled
- Add plan and OmO-Plan to OverridableAgentNameSchema

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-14 22:17:41 +09:00
YeonGyu-Kim
f44555a021 feat(agents): make OmO default agent via build name hack
- Set build agent's display name to 'OmO' (keeps builtIn: true priority)
- Add OmO as subagent (actual execution target when selected)
- Remove explicit tools list from OmO agent (inherit all)
- Rename omo_agent.disable_build to omo_agent.disabled

This hack works around OpenCode's agent selection by key name.
TODO: Use config.default_agent when PR #5313 is released.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-14 21:59:17 +09:00
YeonGyu-Kim
cccc7b7443 docs: fix incorrect default value for disable_build option
The documentation incorrectly stated that disable_build defaults to false,
but the actual code behavior defaults to true (Build agent hidden by default).

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-14 21:45:29 +09:00
YeonGyu-Kim
056b144174 fix(session-notification): gracefully handle notify-send failures on WSL
Add .catch() to notify-send command to prevent GDBus.Error logs
when org.freedesktop.Notifications service is unavailable in WSL environments.

Fixes #47

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-14 21:36:57 +09:00
YeonGyu-Kim
7fef07da2e fix(config): normalize agent names to support case-insensitive config
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-14 21:35:49 +09:00
YeonGyu-Kim
62307d987c docs: document missing hooks and permission options in README
- Add 5 undocumented hooks: Startup Toast, Session Notification,
  Empty Task Response Detector, Grep/Tool Output Truncators
- Add Permission Options section with detailed table (edit, bash,
  webfetch, doom_loop, external_directory)
- Fix JSON schema: add 'build' to agents propertyNames

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-14 20:26:08 +09:00
YeonGyu-Kim
24f2ee0c92 docs: document OmO and build agent override capability in README
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-14 19:52:22 +09:00
17 changed files with 1407 additions and 243 deletions

View File

@@ -466,7 +466,12 @@ Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
- **Anthropic Auto Compact**: Claude 모델이 토큰 제한에 도달하면 자동으로 세션을 요약하고 압축합니다. 수동 개입 없이 작업을 계속할 수 있습니다.
- **Session Recovery**: 세션 에러(누락된 도구 결과, thinking 블록 문제, 빈 메시지 등)에서 자동 복구합니다. 돌다가 세션이 망가지지 않습니다. 망가져도 복구됩니다.
- **Auto Update Checker**: oh-my-opencode의 새 버전이 출시되면 알림을 표시합니다.
- **Startup Toast**: OhMyOpenCode 로드 시 환영 메시지를 표시합니다. 세션을 제대로 시작하기 위한 작은 "oMoMoMo".
- **Background Notification**: 백그라운드 에이전트 작업이 완료되면 알림을 받습니다.
- **Session Notification**: 에이전트가 대기 상태가 되면 OS 알림을 보냅니다. macOS, Linux, Windows에서 작동—에이전트가 입력을 기다릴 때 놓치지 마세요.
- **Empty Task Response Detector**: Task 도구가 빈 응답을 반환하면 감지합니다. 이미 빈 응답이 왔는데 무한정 기다리는 상황을 방지합니다.
- **Grep Output Truncator**: grep은 산더미 같은 텍스트를 반환할 수 있습니다. 남은 컨텍스트 윈도우에 따라 동적으로 출력을 축소합니다—50% 여유 공간 유지, 최대 50k 토큰.
- **Tool Output Truncator**: 같은 아이디어, 더 넓은 범위. Grep, Glob, LSP 도구, AST-grep의 출력을 축소합니다. 한 번의 장황한 검색이 전체 컨텍스트를 잡아먹는 것을 방지합니다.
## 설정
@@ -516,6 +521,34 @@ Google Gemini 모델을 위한 내장 Antigravity OAuth를 활성화합니다:
각 에이전트에서 지원하는 옵션: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
`OmO` (메인 오케스트레이터)와 `build` (기본 에이전트)도 동일한 옵션으로 설정을 오버라이드할 수 있습니다.
#### Permission 옵션
에이전트가 할 수 있는 작업을 세밀하게 제어합니다:
```json
{
"agents": {
"explore": {
"permission": {
"edit": "deny",
"bash": "ask",
"webfetch": "allow"
}
}
}
}
```
| Permission | 설명 | 값 |
|------------|------|-----|
| `edit` | 파일 편집 권한 | `ask` / `allow` / `deny` |
| `bash` | Bash 명령 실행 권한 | `ask` / `allow` / `deny` 또는 명령별: `{ "git": "allow", "rm": "deny" }` |
| `webfetch` | 웹 요청 권한 | `ask` / `allow` / `deny` |
| `doom_loop` | 무한 루프 감지 오버라이드 허용 | `ask` / `allow` / `deny` |
| `external_directory` | 프로젝트 루트 외부 파일 접근 | `ask` / `allow` / `deny` |
또는 ~/.config/opencode/oh-my-opencode.json 혹은 .opencode/oh-my-opencode.json 의 `disabled_agents` 를 사용하여 비활성화할 수 있습니다:
```json
@@ -528,19 +561,42 @@ Google Gemini 모델을 위한 내장 Antigravity OAuth를 활성화합니다:
### OmO Agent
기본 OmO 에이전트 동작을 설정합니다:
활성화 시(기본값), OmO는 두 개의 primary 에이전트를 추가하고 내장 에이전트를 subagent로 강등합니다:
- **OmO**: Primary 오케스트레이터 에이전트 (Claude Opus 4.5)
- **OmO-Plan**: OpenCode plan 에이전트의 모든 설정을 런타임에 상속 (description에 "OhMyOpenCode version" 추가)
- **build**: subagent로 강등
- **plan**: subagent로 강등
OmO를 비활성화하고 원래 build/plan 에이전트를 복원하려면:
```json
{
"omo_agent": {
"disable_build": false
"disabled": true
}
}
```
다른 에이전트처럼 OmO와 OmO-Plan도 커스터마이징할 수 있습니다:
```json
{
"agents": {
"OmO": {
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"OmO-Plan": {
"model": "openai/gpt-5.2"
}
}
}
```
| 옵션 | 기본값 | 설명 |
|------|--------|------|
| `disable_build` | `false` | `true`로 설정하면 기본 Build 에이전트를 숨깁니다. OmO가 유일한 primary 에이전트가 됩니다. |
| `disabled` | `false` | `true`면 OmO 에이전트를 비활성화하고 원래 build/plan을 primary로 복원합니다. `false`(기본값)면 OmO와 OmO-Plan이 primary 에이전트가 됩니다. |
### Hooks
@@ -552,7 +608,7 @@ Google Gemini 모델을 위한 내장 Antigravity OAuth를 활성화합니다:
}
```
사용 가능한 훅: `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

@@ -467,7 +467,12 @@ When agents thrive, you thrive. But I want to help you directly too.
- **Anthropic Auto Compact**: When Claude models hit token limits, automatically summarizes and compacts the session—no manual intervention needed.
- **Session Recovery**: Automatically recovers from session errors (missing tool results, thinking block issues, empty messages). Sessions don't crash mid-run. Even if they do, they recover.
- **Auto Update Checker**: Notifies you when a new version of oh-my-opencode is available.
- **Startup Toast**: Shows a welcome message when OhMyOpenCode loads. A little "oMoMoMo" to start your session right.
- **Background Notification**: Get notified when background agent tasks complete.
- **Session Notification**: Sends OS notifications when agents go idle. Works on macOS, Linux, and Windows—never miss when your agent needs input.
- **Empty Task Response Detector**: Catches when Task tool returns nothing. Warns you about potential agent failures so you don't wait forever for a response that already came back empty.
- **Grep Output Truncator**: Grep can return mountains of text. This dynamically truncates output based on your remaining context window—keeps 50% headroom, caps at 50k tokens.
- **Tool Output Truncator**: Same idea, broader scope. Truncates output from Grep, Glob, LSP tools, and AST-grep. Prevents one verbose search from eating your entire context.
## Configuration
@@ -517,6 +522,34 @@ Override built-in agent settings:
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
You can also override settings for `OmO` (the main orchestrator) and `build` (the default agent) using the same options.
#### Permission Options
Fine-grained control over what agents can do:
```json
{
"agents": {
"explore": {
"permission": {
"edit": "deny",
"bash": "ask",
"webfetch": "allow"
}
}
}
}
```
| Permission | Description | Values |
|------------|-------------|--------|
| `edit` | File editing permission | `ask` / `allow` / `deny` |
| `bash` | Bash command execution | `ask` / `allow` / `deny` or per-command: `{ "git": "allow", "rm": "deny" }` |
| `webfetch` | Web request permission | `ask` / `allow` / `deny` |
| `doom_loop` | Allow infinite loop detection override | `ask` / `allow` / `deny` |
| `external_directory` | Access files outside project root | `ask` / `allow` / `deny` |
Or disable via `disabled_agents` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
```json
@@ -529,19 +562,42 @@ Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `
### OmO Agent
Configure the default OmO agent behavior:
When enabled (default), OmO adds two primary agents and demotes the built-in agents to subagents:
- **OmO**: Primary orchestrator agent (Claude Opus 4.5)
- **OmO-Plan**: Inherits all settings from OpenCode's plan agent at runtime (description appended with "OhMyOpenCode version")
- **build**: Demoted to subagent
- **plan**: Demoted to subagent
To disable OmO and restore the original build/plan agents:
```json
{
"omo_agent": {
"disable_build": false
"disabled": true
}
}
```
You can also customize OmO and OmO-Plan like other agents:
```json
{
"agents": {
"OmO": {
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"OmO-Plan": {
"model": "openai/gpt-5.2"
}
}
}
```
| Option | Default | Description |
|--------|---------|-------------|
| `disable_build` | `false` | When `true`, hides the default Build agent. OmO becomes the only primary agent. |
| `disabled` | `false` | When `true`, disables OmO agents and restores original build/plan as primary. When `false` (default), OmO and OmO-Plan become primary agents. |
### Hooks
@@ -553,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.2",
"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

@@ -366,41 +366,6 @@ export const omoAgent: AgentConfig = {
budgetTokens: 32000,
},
maxTokens: 128000,
tools: {
read: true,
write: true,
edit: true,
multiedit: true,
patch: true,
glob: true,
grep: true,
list: true,
bash: true,
batch: true,
webfetch: true,
websearch: true,
codesearch: true,
todowrite: true,
todoread: true,
task: true,
lsp_hover: true,
lsp_goto_definition: true,
lsp_find_references: true,
lsp_document_symbols: true,
lsp_workspace_symbols: true,
lsp_diagnostics: true,
lsp_rename: true,
lsp_prepare_rename: true,
lsp_code_actions: true,
lsp_code_action_resolve: true,
lsp_servers: true,
ast_grep_search: true,
ast_grep_replace: true,
skill: true,
call_omo_agent: true,
background_task: true,
background_output: true,
},
prompt: OMO_SYSTEM_PROMPT,
color: "#00CED1",
}

View File

@@ -28,7 +28,9 @@ export const BuiltinAgentNameSchema = z.enum([
export const OverridableAgentNameSchema = z.enum([
"build",
"plan",
"OmO",
"OmO-Plan",
"oracle",
"librarian",
"explore",
@@ -58,6 +60,7 @@ export const HookNameSchema = z.enum([
"startup-toast",
"keyword-detector",
"agent-usage-reminder",
"non-interactive-env",
])
export const AgentOverrideConfigSchema = z.object({
@@ -78,7 +81,9 @@ export const AgentOverrideConfigSchema = z.object({
export const AgentOverridesSchema = z.object({
build: AgentOverrideConfigSchema.optional(),
plan: AgentOverrideConfigSchema.optional(),
OmO: AgentOverrideConfigSchema.optional(),
"OmO-Plan": AgentOverrideConfigSchema.optional(),
oracle: AgentOverrideConfigSchema.optional(),
librarian: AgentOverrideConfigSchema.optional(),
explore: AgentOverrideConfigSchema.optional(),
@@ -96,7 +101,7 @@ export const ClaudeCodeConfigSchema = z.object({
})
export const OmoAgentConfigSchema = z.object({
disable_build: z.boolean().optional(),
disabled: z.boolean().optional(),
})
export const OhMyOpenCodeConfigSchema = 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

@@ -28,7 +28,7 @@ TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
## WORKFLOW
1. Analyze the request and identify required capabilities
2. Spawn exploration/librarian agents via background_task in PARALLEL (10+ if needed)
3. Use planning agents to create detailed work breakdown
3. Always Use Plan agent with gathered context to create detailed work breakdown
4. Execute with continuous verification against original requirements
</ultrawork-mode>

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

@@ -56,7 +56,7 @@ async function sendNotification(
await ctx.$`osascript -e ${"display notification \"" + escapedMessage + "\" with title \"" + escapedTitle + "\""}`
break
case "linux":
await ctx.$`notify-send ${escapedTitle} ${escapedMessage}`
await ctx.$`notify-send ${escapedTitle} ${escapedMessage}`.catch(() => {})
break
case "win32":
await ctx.$`powershell -Command ${"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); [System.Windows.Forms.MessageBox]::Show('" + escapedMessage + "', '" + escapedTitle + "')"}`

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 {
@@ -65,11 +66,36 @@ function getUserConfigDir(): string {
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
}
const AGENT_NAME_MAP: Record<string, string> = {
omo: "OmO",
build: "build",
oracle: "oracle",
librarian: "librarian",
explore: "explore",
"frontend-ui-ux-engineer": "frontend-ui-ux-engineer",
"document-writer": "document-writer",
"multimodal-looker": "multimodal-looker",
};
function normalizeAgentNames(agents: Record<string, unknown>): Record<string, unknown> {
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(agents)) {
const normalizedKey = AGENT_NAME_MAP[key.toLowerCase()] ?? key;
normalized[normalizedKey] = value;
}
return normalized;
}
function loadConfigFromPath(configPath: string): OhMyOpenCodeConfig | null {
try {
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, "utf-8");
const rawConfig = JSON.parse(content);
if (rawConfig.agents && typeof rawConfig.agents === "object") {
rawConfig.agents = normalizeAgentNames(rawConfig.agents);
}
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
if (!result.success) {
@@ -213,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" });
@@ -254,15 +283,41 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const userAgents = (pluginConfig.claude_code?.agents ?? true) ? loadUserAgents() : {};
const projectAgents = (pluginConfig.claude_code?.agents ?? true) ? loadProjectAgents() : {};
const shouldHideBuild = pluginConfig.omo_agent?.disable_build !== false;
const isOmoEnabled = pluginConfig.omo_agent?.disabled !== true;
config.agent = {
...builtinAgents,
...userAgents,
...projectAgents,
...config.agent,
...(shouldHideBuild ? { build: { mode: "subagent" } } : {}),
};
if (isOmoEnabled && builtinAgents.OmO) {
// TODO: When OpenCode releases `default_agent` config option (PR #5313),
// use `config.default_agent = "OmO"` instead of demoting build/plan.
// Tracking: https://github.com/sst/opencode/pull/5313
const { name: _planName, ...planConfigWithoutName } = config.agent?.plan ?? {};
const omoPlanOverride = pluginConfig.agents?.["OmO-Plan"];
const omoPlanBase = {
...builtinAgents.OmO,
...planConfigWithoutName,
description: `${config.agent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
color: config.agent?.plan?.color ?? "#6495ED",
};
const omoPlanConfig = omoPlanOverride ? deepMerge(omoPlanBase, omoPlanOverride) : omoPlanBase;
config.agent = {
OmO: builtinAgents.OmO,
"OmO-Plan": omoPlanConfig,
...Object.fromEntries(Object.entries(builtinAgents).filter(([k]) => k !== "OmO")),
...userAgents,
...projectAgents,
...config.agent,
build: { ...config.agent?.build, mode: "subagent" },
plan: { ...config.agent?.plan, mode: "subagent" },
};
} else {
config.agent = {
...builtinAgents,
...userAgents,
...projectAgents,
...config.agent,
};
}
config.tools = {
...config.tools,
@@ -428,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()) {