Compare commits

..

13 Commits

Author SHA1 Message Date
github-actions[bot]
60d9513d3a release: v1.0.1 2025-12-13 05:06:31 +00:00
YeonGyu-Kim
55bc8f08df refactor(ultrawork-mode): use history injection instead of direct message modification
- Replace direct parts[idx].text modification with injectHookMessage
- Context now injected via filesystem (like UserPromptSubmitHook)
- Preserves original user message without modification

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 14:05:17 +09:00
YeonGyu-Kim
0ac4d223f9 feat(think-mode): inject thinking config with maxTokens for extended thinking
- Actually inject THINKING_CONFIGS into message (was defined but unused)
- Add maxTokens: 128000 for Anthropic (required for extended thinking)
- Add maxTokens: 64000 for Amazon Bedrock
- Track thinkingConfigInjected state

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 14:05:02 +09:00
YeonGyu-Kim
19b3690499 docs: add Ultrawork Mode hook documentation
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 14:02:10 +09:00
Junho Yeo
564c8ae8bf fix: use lstatSync instead of statSync for symlink detection (#32) 2025-12-13 13:58:02 +09:00
github-actions[bot]
03c61bf591 release: v1.0.0 2025-12-13 04:53:01 +00:00
YeonGyu-Kim
f57aa39d53 feat(hooks): add ultrawork-mode hook for automatic agent orchestration guidance
When "ultrawork" or "ulw" keyword is detected in user prompt:
- Injects ULTRAWORK_CONTEXT with agent-agnostic guidance
- Executes AFTER CC hooks (UserPromptSubmit etc.)
- Follows existing hook pattern (think-mode style)

Key features:
- Agent orchestration principles (by capability, not name)
- Parallel execution rules
- TODO tracking enforcement
- Delegation guidance

Closes #31

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 13:44:34 +09:00
YeonGyu-Kim
41a318df66 fix(background-task): send notification to parent session instead of main session
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 13:36:31 +09:00
YeonGyu-Kim
e533a35109 feat(antigravity): add GCP permission error retry with exponential backoff
- Add retry logic for 403 GCP permission errors (max 10 retries)
- Implement exponential backoff with 2s cap (200ms → 400ms → 800ms → 2000ms)
- Detect patterns: PERMISSION_DENIED, Cloud AI Companion API not enabled, etc.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 13:26:25 +09:00
YeonGyu-Kim
934d4bcf32 docs: update LLM agent guide with Google Auth recommendation and disabled_hooks section
- Change warning to allow Google Auth setup (google_auth: true) by default
- Clarify that only model changes and feature disabling require explicit user request
- Add missing disabled_hooks documentation to README.ko.md

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 13:26:25 +09:00
YeonGyu-Kim
91ae0cc67d feat(background-task): show original prompt and last message in running task status
- Add prompt field to BackgroundTask to store original prompt
- Add lastMessage/lastMessageAt to TaskProgress for real-time monitoring
- Extract last assistant message during polling
- Update formatTaskStatus() to display prompt (truncated 300 chars) and
  last message (truncated 500 chars) with timestamp

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 13:26:25 +09:00
YeonGyu-Kim
7859f0dd2d fix(hooks): add session-notification to disabled_hooks with race/memory fixes
- Add session-notification to HookNameSchema and schema.json
- Integrate session-notification into disabled_hooks conditional creation
- Fix race condition with version-based invalidation
- Fix memory leak with maxTrackedSessions cleanup
- Add missing activity event types (message.created, tool.execute.*)
- Document disabled_hooks configuration in README

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 13:26:25 +09:00
Claude
e131491db4 feat(config): add disabled_hooks option for selective hook disabling
Allow users to individually disable built-in hooks via the
`disabled_hooks` configuration option in oh-my-opencode.json.

This addresses issue #28 where users requested the ability to
selectively disable hooks (e.g., comment-checker) that may
conflict with their workflow.

Available hooks:
- todo-continuation-enforcer
- context-window-monitor
- session-recovery
- comment-checker
- grep-output-truncator
- directory-agents-injector
- directory-readme-injector
- empty-task-response-detector
- think-mode
- anthropic-auto-compact
- rules-injector
- background-notification
- auto-update-checker

Closes #28
2025-12-13 13:26:25 +09:00
22 changed files with 561 additions and 94 deletions

View File

@@ -110,7 +110,7 @@ cat ~/.config/opencode/opencode.json # plugin 배열에 "oh-my-opencode"가 있
### 4단계: 인증정보 설정
plugin 에 oh my opencode 를 추가했다면, tmux 를 사용하여 사용자의 인증 설정을 도와주세요.
plugin 에 oh my opencode 를 추가했다면, tmux 를 사용하여 사용자의 인증 설정을 도와주세요. **기본적으로 Google Auth (4.2단계) 설정을 권장합니다.**
#### 4.1 Anthropic (Claude)
@@ -123,7 +123,9 @@ opencode auth login
#### 4.2 Google Gemini (내장 Antigravity OAuth)
먼저 `~/.config/opencode/oh-my-opencode.json` (또는 프로젝트 레벨의 `.opencode/oh-my-opencode.json`)에서 Google auth를 활성화:
**권장되는 기본 설정입니다.**
먼저 주도적으로 `~/.config/opencode/oh-my-opencode.json` (또는 프로젝트 레벨의 `.opencode/oh-my-opencode.json`)에서 Google auth를 활성화하세요:
```json
{
@@ -170,9 +172,9 @@ opencode auth login
### ⚠️ 주의
**사용자가 명시적으로 요청하지 않는 한, `oh-my-opencode.json` 설정 파일을 생성하거나 수정하지 마세요.**
**사용자가 명시적으로 요청하지 않는 한, 모델 설정을 변경하거나 기능(에이전트, 훅, MCP)을 비활성화하지 마세요.**
플러그인은 설정 파일 없이도 기본값으로 완벽하게 동작합니다. 설정 파일은 사용자가 커스터마이징을 원할 때만 필요합니다.
플러그인은 기본값으로 완벽하게 동작합니다. 권장 사항인 `google_auth: true` 설정을 제외하고는, 명시적인 요청 없이 다른 설정을 변경하거나 기능을 끄지 마세요.
</details>
@@ -330,10 +332,23 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
- Use camelCase for function names
```
- **Think Mode**: 확장된 사고(Extended Thinking)가 필요한 상황을 자동으로 감지하고 모드를 전환합니다. 사용자가 깊은 사고를 요청하는 표현(예: "think deeply", "ultrathink")을 감지하면, 추론 능력을 극대화하도록 모델 설정을 동적으로 조정합니다.
- **Ultrawork Mode**: 사용자가 "ultrawork" 또는 "ulw" 키워드를 입력하면 자동으로 에이전트 오케스트레이션 가이드를 주입합니다. 메인 에이전트가 모든 가용한 전문 에이전트(탐색, 사서, 계획, UI)를 백그라운드 작업을 통해 병렬로 최대한 활용하도록 강제하며, 엄격한 TODO 추적 및 검증 프로토콜을 따르게 합니다.
- **Anthropic Auto Compact**: Anthropic 모델 사용 시 컨텍스트 한계에 도달하면 대화 기록을 자동으로 압축하여 효율적으로 관리합니다.
- **Empty Task Response Detector**: 서브 에이전트가 수행한 작업이 비어있거나 무의미한 응답을 반환하는 경우를 감지하여, 오류 없이 우아하게 처리합니다.
- **Grep Output Truncator**: Grep 검색 결과가 너무 길어 컨텍스트를 장악해버리는 것을 방지하기 위해, 과도한 출력을 자동으로 자릅니다.
필요 없는 훅이 있다면, `~/.config/opencode/oh-my-opencode.json` 혹은 `.opencode/oh-my-opencode.json`의 `disabled_hooks`를 사용하여 비활성화할 수 있습니다:
```json
{
"disabled_hooks": ["session-notification", "comment-checker"]
}
```
사용 가능한 훅: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `ultrawork-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`
> **참고**: `disabled_hooks`는 Oh My OpenCode의 내장 훅을 제어합니다. Claude Code의 `settings.json` 훅을 비활성화하려면 `claude_code.hooks: false`를 대신 사용하세요 ([호환성 토글](#호환성-토글) 참고).
### Claude Code 호환성
Oh My OpenCode는 Claude Code 설정과 완벽하게 호환됩니다. Claude Code를 사용하셨다면, 기존 설정을 그대로 사용할 수 있습니다.

View File

@@ -109,7 +109,7 @@ cat ~/.config/opencode/opencode.json # Should contain "oh-my-opencode" in plugi
### Step 4: Configure Authentication
Once `oh-my-opencode` is added to the plugin list, use `tmux` to assist the user with authentication setup.
Once `oh-my-opencode` is added to the plugin list, use `tmux` to assist the user with authentication setup. **It is RECOMMENDED to set up Google Auth (Step 4.2) by default.**
#### 4.1 Anthropic (Claude)
@@ -122,7 +122,9 @@ opencode auth login
#### 4.2 Google Gemini (Built-in Antigravity OAuth)
First, enable Google auth in `~/.config/opencode/oh-my-opencode.json` (or `.opencode/oh-my-opencode.json` for project-level):
**This is the RECOMMENDED default configuration.**
First, proactively enable Google auth in `~/.config/opencode/oh-my-opencode.json` (or `.opencode/oh-my-opencode.json` for project-level):
```json
{
@@ -169,9 +171,9 @@ For users who prefer proxy-based authentication, [VibeProxy](https://github.com/
### ⚠️ Warning
**Do NOT create or modify the `oh-my-opencode.json` configuration file unless the user explicitly requests it.**
**Do NOT modify model settings or disable features (agents, hooks, MCPs) unless the user explicitly requests it.**
The plugin works perfectly with default settings without any configuration file. The configuration file is only needed when the user wants customization.
The plugin works perfectly with default settings. While you SHOULD enable `google_auth: true` as recommended, do not alter other configurations or disable features without a specific request.
</details>
@@ -328,10 +330,23 @@ Example workflow:
- Use camelCase for function names
```
- **Think Mode**: Automatic extended thinking detection and mode switching. Detects when user requests deep thinking (e.g., "think deeply", "ultrathink") and dynamically adjusts model settings for enhanced reasoning.
- **Ultrawork Mode**: When user triggers "ultrawork" or "ulw" keywords, automatically injects agent orchestration guidance. Forces the main agent to leverage all available specialized agents (exploration, librarian, planning, UI) via background tasks in parallel, with strict TODO tracking and verification protocols.
- **Anthropic Auto Compact**: Automatically compacts conversation history when approaching context limits for Anthropic models.
- **Empty Task Response Detector**: Detects when subagent tasks return empty or meaningless responses and handles gracefully.
- **Grep Output Truncator**: Prevents grep output from overwhelming the context by truncating excessively long results.
You can disable specific built-in hooks using `disabled_hooks` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
```json
{
"disabled_hooks": ["session-notification", "comment-checker"]
}
```
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `ultrawork-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`
> **Note**: `disabled_hooks` controls Oh My OpenCode's built-in hooks. To disable Claude Code's `settings.json` hooks, use `claude_code.hooks: false` instead (see [Compatibility Toggles](#compatibility-toggles)).
### Claude Code Compatibility
Oh My OpenCode provides seamless Claude Code configuration compatibility. If you've been using Claude Code, your existing setup works out of the box.

View File

@@ -31,6 +31,29 @@
]
}
},
"disabled_hooks": {
"type": "array",
"description": "List of built-in hooks to disable. Useful for selectively disabling hooks that may conflict with your workflow.",
"items": {
"type": "string",
"enum": [
"todo-continuation-enforcer",
"context-window-monitor",
"session-recovery",
"session-notification",
"comment-checker",
"grep-output-truncator",
"directory-agents-injector",
"directory-readme-injector",
"empty-task-response-detector",
"think-mode",
"anthropic-auto-compact",
"rules-injector",
"background-notification",
"auto-update-checker"
]
}
},
"agents": {
"type": "object",
"propertyNames": {

View File

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

View File

@@ -70,6 +70,21 @@ function isRetryableError(status: number): boolean {
return false
}
const GCP_PERMISSION_ERROR_PATTERNS = [
"PERMISSION_DENIED",
"does not have permission",
"Cloud AI Companion API has not been used",
"has not been enabled",
] as const
function isGcpPermissionError(text: string): boolean {
return GCP_PERMISSION_ERROR_PATTERNS.some((pattern) => text.includes(pattern))
}
function calculateRetryDelay(attempt: number): number {
return Math.min(200 * Math.pow(2, attempt), 2000)
}
async function isRetryableResponse(response: Response): Promise<boolean> {
if (isRetryableError(response.status)) return true
if (response.status === 403) {
@@ -155,23 +170,43 @@ async function attemptFetch(
debugLog(`[REQ] streaming=${transformed.streaming}, url=${transformed.url}`)
const response = await fetch(transformed.url, {
method: init.method || "POST",
headers: transformed.headers,
body: JSON.stringify(transformed.body),
signal: init.signal,
})
const maxPermissionRetries = 10
for (let attempt = 0; attempt <= maxPermissionRetries; attempt++) {
const response = await fetch(transformed.url, {
method: init.method || "POST",
headers: transformed.headers,
body: JSON.stringify(transformed.body),
signal: init.signal,
})
debugLog(
`[RESP] status=${response.status} content-type=${response.headers.get("content-type") ?? ""} url=${response.url}`
)
debugLog(
`[RESP] status=${response.status} content-type=${response.headers.get("content-type") ?? ""} url=${response.url}`
)
if (!response.ok && (await isRetryableResponse(response))) {
debugLog(`Endpoint failed: ${endpoint} (status: ${response.status}), trying next`)
return null
if (response.status === 403) {
try {
const text = await response.clone().text()
if (isGcpPermissionError(text)) {
if (attempt < maxPermissionRetries) {
const delay = calculateRetryDelay(attempt)
debugLog(`[RETRY] GCP permission error, retry ${attempt + 1}/${maxPermissionRetries} after ${delay}ms`)
await new Promise((resolve) => setTimeout(resolve, delay))
continue
}
debugLog(`[RETRY] GCP permission error, max retries exceeded`)
}
} catch {}
}
if (!response.ok && (await isRetryableResponse(response))) {
debugLog(`Endpoint failed: ${endpoint} (status: ${response.status}), trying next`)
return null
}
return response
}
return response
return null
} catch (error) {
debugLog(
`Endpoint failed: ${endpoint} (${error instanceof Error ? error.message : "Unknown error"}), trying next`

View File

@@ -4,6 +4,7 @@ export {
AgentOverridesSchema,
McpNameSchema,
AgentNameSchema,
HookNameSchema,
} from "./schema"
export type {
@@ -12,4 +13,5 @@ export type {
AgentOverrides,
McpName,
AgentName,
HookName,
} from "./schema"

View File

@@ -24,6 +24,24 @@ export const AgentNameSchema = z.enum([
"document-writer",
])
export const HookNameSchema = z.enum([
"todo-continuation-enforcer",
"context-window-monitor",
"session-recovery",
"session-notification",
"comment-checker",
"grep-output-truncator",
"directory-agents-injector",
"directory-readme-injector",
"empty-task-response-detector",
"think-mode",
"anthropic-auto-compact",
"rules-injector",
"background-notification",
"auto-update-checker",
"ultrawork-mode",
])
export const AgentOverrideConfigSchema = z.object({
model: z.string().optional(),
temperature: z.number().min(0).max(2).optional(),
@@ -62,6 +80,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
$schema: z.string().optional(),
disabled_mcps: z.array(McpNameSchema).optional(),
disabled_agents: z.array(AgentNameSchema).optional(),
disabled_hooks: z.array(HookNameSchema).optional(),
agents: AgentOverridesSchema.optional(),
claude_code: ClaudeCodeConfigSchema.optional(),
google_auth: z.boolean().optional(),
@@ -71,5 +90,6 @@ export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>
export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
export type AgentName = z.infer<typeof AgentNameSchema>
export type HookName = z.infer<typeof HookNameSchema>
export { McpNameSchema, type McpName } from "../mcp/types"

View File

@@ -4,7 +4,6 @@ import type {
LaunchInput,
} from "./types"
import { log } from "../../shared/logger"
import { getMainSessionID } from "../claude-code-session-state"
type OpencodeClient = PluginInput["client"]
@@ -59,6 +58,7 @@ export class BackgroundManager {
parentSessionID: input.parentSessionID,
parentMessageID: input.parentMessageID,
description: input.description,
prompt: input.prompt,
agent: input.agent,
status: "running",
startedAt: new Date(),
@@ -239,25 +239,19 @@ export class BackgroundManager {
const message = `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished in ${duration}. Use background_output with task_id="${task.id}" to get results.`
const mainSessionID = getMainSessionID()
if (!mainSessionID) {
log("[background-agent] No main session ID available, relying on pending queue")
return
}
log("[background-agent] Sending notification to main session:", mainSessionID)
log("[background-agent] Sending notification to parent session:", { parentSessionID: task.parentSessionID })
setTimeout(async () => {
try {
await this.client.session.prompt({
path: { id: mainSessionID },
path: { id: task.parentSessionID },
body: {
parts: [{ type: "text", text: message }],
},
query: { directory: this.directory },
})
this.clearNotificationsForTask(task.id)
log("[background-agent] Successfully sent prompt to main session")
log("[background-agent] Successfully sent prompt to parent session:", { parentSessionID: task.parentSessionID })
} catch (error) {
log("[background-agent] prompt failed:", String(error))
}
@@ -316,7 +310,7 @@ export class BackgroundManager {
if (!messagesResult.error && messagesResult.data) {
const messages = messagesResult.data as Array<{
info?: { role?: string }
parts?: Array<{ type?: string; tool?: string; name?: string }>
parts?: Array<{ type?: string; tool?: string; name?: string; text?: string }>
}>
const assistantMsgs = messages.filter(
(m) => m.info?.role === "assistant"
@@ -324,6 +318,7 @@ export class BackgroundManager {
let toolCalls = 0
let lastTool: string | undefined
let lastMessage: string | undefined
for (const msg of assistantMsgs) {
const parts = msg.parts ?? []
@@ -332,6 +327,9 @@ export class BackgroundManager {
toolCalls++
lastTool = part.tool || part.name || "unknown"
}
if (part.type === "text" && part.text) {
lastMessage = part.text
}
}
}
@@ -341,6 +339,10 @@ export class BackgroundManager {
task.progress.toolCalls = toolCalls
task.progress.lastTool = lastTool
task.progress.lastUpdate = new Date()
if (lastMessage) {
task.progress.lastMessage = lastMessage
task.progress.lastMessageAt = new Date()
}
}
} catch (error) {
log("[background-agent] Poll error for task:", { taskId: task.id, error })

View File

@@ -8,6 +8,8 @@ export interface TaskProgress {
toolCalls: number
lastTool?: string
lastUpdate: Date
lastMessage?: string
lastMessageAt?: Date
}
export interface BackgroundTask {
@@ -16,6 +18,7 @@ export interface BackgroundTask {
parentSessionID: string
parentMessageID: string
description: string
prompt: string
agent: string
status: BackgroundTaskStatus
startedAt: Date

View File

@@ -1,4 +1,4 @@
import { existsSync, readdirSync, readFileSync, statSync, readlinkSync } from "fs"
import { existsSync, readdirSync, readFileSync, lstatSync, readlinkSync } from "fs"
import { homedir } from "os"
import { join, resolve } from "path"
import { parseFrontmatter } from "../../shared/frontmatter"
@@ -22,8 +22,12 @@ function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkillAsC
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
let resolvedPath = skillPath
if (statSync(skillPath, { throwIfNoEntry: false })?.isSymbolicLink()) {
resolvedPath = resolve(skillPath, "..", readlinkSync(skillPath))
try {
if (lstatSync(skillPath, { throwIfNoEntry: false })?.isSymbolicLink()) {
resolvedPath = resolve(skillPath, "..", readlinkSync(skillPath))
}
} catch {
continue
}
const skillMdPath = join(resolvedPath, "SKILL.md")

View File

@@ -13,3 +13,4 @@ export { createClaudeCodeHooksHook } from "./claude-code-hooks";
export { createRulesInjectorHook } from "./rules-injector";
export { createBackgroundNotificationHook } from "./background-notification"
export { createAutoUpdateCheckerHook } from "./auto-update-checker";
export { createUltraworkModeHook } from "./ultrawork-mode";

View File

@@ -17,6 +17,8 @@ interface SessionNotificationConfig {
idleConfirmationDelay?: number
/** Skip notification if there are incomplete todos (default: true) */
skipIfIncompleteTodos?: boolean
/** Maximum number of sessions to track before cleanup (default: 100) */
maxTrackedSessions?: number
}
type Platform = "darwin" | "linux" | "win32" | "unsupported"
@@ -103,12 +105,31 @@ export function createSessionNotification(
soundPath: defaultSoundPath,
idleConfirmationDelay: 1500,
skipIfIncompleteTodos: true,
maxTrackedSessions: 100,
...config,
}
const notifiedSessions = new Set<string>()
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
const sessionActivitySinceIdle = new Set<string>()
// Track notification execution version to handle race conditions
const notificationVersions = new Map<string, number>()
function cleanupOldSessions() {
const maxSessions = mergedConfig.maxTrackedSessions
if (notifiedSessions.size > maxSessions) {
const sessionsToRemove = Array.from(notifiedSessions).slice(0, notifiedSessions.size - maxSessions)
sessionsToRemove.forEach(id => notifiedSessions.delete(id))
}
if (sessionActivitySinceIdle.size > maxSessions) {
const sessionsToRemove = Array.from(sessionActivitySinceIdle).slice(0, sessionActivitySinceIdle.size - maxSessions)
sessionsToRemove.forEach(id => sessionActivitySinceIdle.delete(id))
}
if (notificationVersions.size > maxSessions) {
const sessionsToRemove = Array.from(notificationVersions.keys()).slice(0, notificationVersions.size - maxSessions)
sessionsToRemove.forEach(id => notificationVersions.delete(id))
}
}
function cancelPendingNotification(sessionID: string) {
const timer = pendingTimers.get(sessionID)
@@ -117,6 +138,8 @@ export function createSessionNotification(
pendingTimers.delete(sessionID)
}
sessionActivitySinceIdle.add(sessionID)
// Increment version to invalidate any in-flight notifications
notificationVersions.set(sessionID, (notificationVersions.get(sessionID) ?? 0) + 1)
}
function markSessionActivity(sessionID: string) {
@@ -124,9 +147,14 @@ export function createSessionNotification(
notifiedSessions.delete(sessionID)
}
async function executeNotification(sessionID: string) {
async function executeNotification(sessionID: string, version: number) {
pendingTimers.delete(sessionID)
// Race condition fix: check if version matches (activity happened during async wait)
if (notificationVersions.get(sessionID) !== version) {
return
}
if (sessionActivitySinceIdle.has(sessionID)) {
sessionActivitySinceIdle.delete(sessionID)
return
@@ -136,9 +164,17 @@ export function createSessionNotification(
if (mergedConfig.skipIfIncompleteTodos) {
const hasPendingWork = await hasIncompleteTodos(ctx, sessionID)
// Re-check version after async call (race condition fix)
if (notificationVersions.get(sessionID) !== version) {
return
}
if (hasPendingWork) return
}
if (notificationVersions.get(sessionID) !== version) {
return
}
notifiedSessions.add(sessionID)
try {
@@ -172,20 +208,34 @@ export function createSessionNotification(
if (pendingTimers.has(sessionID)) return
sessionActivitySinceIdle.delete(sessionID)
const currentVersion = (notificationVersions.get(sessionID) ?? 0) + 1
notificationVersions.set(sessionID, currentVersion)
const timer = setTimeout(() => {
executeNotification(sessionID)
executeNotification(sessionID, currentVersion)
}, mergedConfig.idleConfirmationDelay)
pendingTimers.set(sessionID, timer)
cleanupOldSessions()
return
}
if (event.type === "message.updated") {
if (event.type === "message.updated" || event.type === "message.created") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
if (sessionID) {
markSessionActivity(sessionID)
}
return
}
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
markSessionActivity(sessionID)
}
return
}
if (event.type === "session.deleted") {
@@ -194,6 +244,7 @@ export function createSessionNotification(
cancelPendingNotification(sessionInfo.id)
notifiedSessions.delete(sessionInfo.id)
sessionActivitySinceIdle.delete(sessionInfo.id)
notificationVersions.delete(sessionInfo.id)
}
}
}

View File

@@ -1,6 +1,7 @@
import { detectThinkKeyword, extractPromptText } from "./detector"
import { getHighVariant, isAlreadyHighVariant } from "./switcher"
import { getHighVariant, isAlreadyHighVariant, getThinkingConfig } from "./switcher"
import type { ThinkModeState, ThinkModeInput } from "./types"
import { log } from "../../shared"
export * from "./detector"
export * from "./switcher"
@@ -23,6 +24,7 @@ export function createThinkModeHook() {
const state: ThinkModeState = {
requested: false,
modelSwitched: false,
thinkingConfigInjected: false,
}
if (!detectThinkKeyword(promptText)) {
@@ -47,17 +49,31 @@ export function createThinkModeHook() {
}
const highVariant = getHighVariant(currentModel.modelID)
const thinkingConfig = getThinkingConfig(currentModel.providerID, currentModel.modelID)
if (!highVariant) {
thinkModeState.set(sessionID, state)
return
if (highVariant) {
output.message.model = {
providerID: currentModel.providerID,
modelID: highVariant,
}
state.modelSwitched = true
log("Think mode: model switched to high variant", {
sessionID,
from: currentModel.modelID,
to: highVariant,
})
}
output.message.model = {
providerID: currentModel.providerID,
modelID: highVariant,
if (thinkingConfig) {
Object.assign(output.message, thinkingConfig)
state.thinkingConfigInjected = true
log("Think mode: thinking config injected", {
sessionID,
provider: currentModel.providerID,
config: thinkingConfig,
})
}
state.modelSwitched = true
thinkModeState.set(sessionID, state)
},

View File

@@ -55,12 +55,14 @@ export const THINKING_CONFIGS: Record<string, Record<string, unknown>> = {
type: "enabled",
budgetTokens: 64000,
},
maxTokens: 128000,
},
"amazon-bedrock": {
reasoningConfig: {
type: "enabled",
budgetTokens: 32000,
},
maxTokens: 64000,
},
google: {
providerOptions: {

View File

@@ -1,6 +1,7 @@
export interface ThinkModeState {
requested: boolean
modelSwitched: boolean
thinkingConfigInjected: boolean
providerID?: string
modelID?: string
}

View File

@@ -0,0 +1,48 @@
/** Keyword patterns - "ultrawork", "ulw" (case-insensitive, word boundary) */
export const ULTRAWORK_PATTERNS = [/\bultrawork\b/i, /\bulw\b/i]
/** Code block pattern to exclude from keyword detection */
export const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g
/** Inline code pattern to exclude */
export const INLINE_CODE_PATTERN = /`[^`]+`/g
/**
* ULTRAWORK_CONTEXT - Agent-Agnostic Guidance
*
* Key principles:
* - NO specific agent names (oracle, librarian, etc.)
* - Only provide guidance based on agent role/capability
* - Emphasize parallel execution, TODO tracking, delegation
*/
export const ULTRAWORK_CONTEXT = `<ultrawork-mode>
[CODE RED] Maximum precision required. Ultrathink before acting.
YOU MUST LEVERAGE ALL AVAILABLE AGENTS TO THEIR FULLEST POTENTIAL.
TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
## AGENT UTILIZATION PRINCIPLES (by capability, not by name)
- **Codebase Exploration**: Spawn exploration agents using BACKGROUND TASKS for file patterns, internal implementations, project structure
- **Documentation & References**: Use librarian-type agents via BACKGROUND TASKS for API references, examples, external library docs
- **Planning & Strategy**: NEVER plan yourself - ALWAYS spawn a dedicated planning agent for work breakdown
- **High-IQ Reasoning**: Leverage specialized agents for architecture decisions, code review, strategic planning
- **Frontend/UI Tasks**: Delegate to UI-specialized agents for design and implementation
## EXECUTION RULES
- **TODO**: Track EVERY step. Mark complete IMMEDIATELY after each.
- **PARALLEL**: Fire independent agent calls simultaneously via background_task - NEVER wait sequentially.
- **BACKGROUND FIRST**: Use background_task for exploration/research agents (10+ concurrent if needed).
- **VERIFY**: Re-read request after completion. Check ALL requirements met before reporting done.
- **DELEGATE**: Don't do everything yourself - orchestrate specialized agents for their strengths.
## 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
4. Execute with continuous verification against original requirements
</ultrawork-mode>
---
`

View File

@@ -0,0 +1,33 @@
import {
ULTRAWORK_PATTERNS,
CODE_BLOCK_PATTERN,
INLINE_CODE_PATTERN,
} from "./constants"
/**
* Remove code blocks and inline code from text.
* Prevents false positives when keywords appear in code.
*/
export function removeCodeBlocks(text: string): string {
return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, "")
}
/**
* Detect ultrawork keywords in text (excluding code blocks).
*/
export function detectUltraworkKeyword(text: string): boolean {
const textWithoutCode = removeCodeBlocks(text)
return ULTRAWORK_PATTERNS.some((pattern) => pattern.test(textWithoutCode))
}
/**
* Extract text content from message parts.
*/
export function extractPromptText(
parts: Array<{ type: string; text?: string }>
): string {
return parts
.filter((p) => p.type === "text")
.map((p) => p.text || "")
.join("")
}

View File

@@ -0,0 +1,97 @@
import { detectUltraworkKeyword, extractPromptText } from "./detector"
import { ULTRAWORK_CONTEXT } from "./constants"
import type { UltraworkModeState } from "./types"
import { log } from "../../shared"
import { injectHookMessage } from "../../features/hook-message-injector"
export * from "./detector"
export * from "./constants"
export * from "./types"
const ultraworkModeState = new Map<string, UltraworkModeState>()
export function clearUltraworkModeState(sessionID: string): void {
ultraworkModeState.delete(sessionID)
}
export function createUltraworkModeHook() {
return {
/**
* chat.message hook - detect ultrawork/ulw keywords, inject context via history
*
* Execution timing: AFTER claudeCodeHooks["chat.message"]
* Behavior:
* 1. Extract text from user prompt
* 2. Detect ultrawork/ulw keywords (excluding code blocks)
* 3. If detected, inject ULTRAWORK_CONTEXT via injectHookMessage (history injection)
*/
"chat.message": async (
input: {
sessionID: string
agent?: string
model?: { providerID: string; modelID: string }
messageID?: string
},
output: {
message: Record<string, unknown>
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
}
): Promise<void> => {
const state: UltraworkModeState = {
detected: false,
injected: false,
}
const promptText = extractPromptText(output.parts)
if (!detectUltraworkKeyword(promptText)) {
ultraworkModeState.set(input.sessionID, state)
return
}
state.detected = true
log("Ultrawork keyword detected", { sessionID: input.sessionID })
const message = output.message as {
agent?: string
model?: { modelID?: string; providerID?: string }
path?: { cwd?: string; root?: string }
tools?: Record<string, boolean>
}
const success = injectHookMessage(input.sessionID, ULTRAWORK_CONTEXT, {
agent: message.agent,
model: message.model,
path: message.path,
tools: message.tools,
})
if (success) {
state.injected = true
log("Ultrawork context injected via history", { sessionID: input.sessionID })
} else {
log("Ultrawork context injection failed", { sessionID: input.sessionID })
}
ultraworkModeState.set(input.sessionID, state)
},
/**
* event hook - cleanup session state on deletion
*/
event: async ({
event,
}: {
event: { type: string; properties?: unknown }
}) => {
if (event.type === "session.deleted") {
const props = event.properties as
| { info?: { id?: string } }
| undefined
if (props?.info?.id) {
ultraworkModeState.delete(props.info.id)
}
}
},
}
}

View File

@@ -0,0 +1,20 @@
export interface UltraworkModeState {
/** Whether ultrawork keyword was detected */
detected: boolean
/** Whether context was injected */
injected: boolean
}
export interface ModelRef {
providerID: string
modelID: string
}
export interface MessageWithModel {
model?: ModelRef
}
export interface UltraworkModeInput {
parts: Array<{ type: string; text?: string }>
message: MessageWithModel
}

View File

@@ -4,6 +4,7 @@ import {
createTodoContinuationEnforcer,
createContextWindowMonitorHook,
createSessionRecoveryHook,
createSessionNotification,
createCommentCheckerHooks,
createGrepOutputTruncatorHook,
createDirectoryAgentsInjectorHook,
@@ -15,6 +16,7 @@ import {
createRulesInjectorHook,
createBackgroundNotificationHook,
createAutoUpdateCheckerHook,
createUltraworkModeHook,
} from "./hooks";
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
import {
@@ -42,7 +44,7 @@ import { updateTerminalTitle } from "./features/terminal";
import { builtinTools, createCallOmoAgent, createBackgroundTools } from "./tools";
import { BackgroundManager } from "./features/background-agent";
import { createBuiltinMcps } from "./mcp";
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type HookName } from "./config";
import { log, deepMerge } from "./shared";
import * as fs from "fs";
import * as path from "path";
@@ -103,6 +105,12 @@ function mergeConfigs(
...(override.disabled_mcps ?? []),
]),
],
disabled_hooks: [
...new Set([
...(base.disabled_hooks ?? []),
...(override.disabled_hooks ?? []),
]),
],
claude_code: deepMerge(base.claude_code, override.claude_code),
};
}
@@ -135,6 +143,7 @@ function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
agents: config.agents,
disabled_agents: config.disabled_agents,
disabled_mcps: config.disabled_mcps,
disabled_hooks: config.disabled_hooks,
claude_code: config.claude_code,
});
return config;
@@ -142,34 +151,70 @@ function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const pluginConfig = loadPluginConfig(ctx.directory);
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName);
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx);
const contextWindowMonitor = createContextWindowMonitorHook(ctx);
const sessionRecovery = createSessionRecoveryHook(ctx);
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
? createTodoContinuationEnforcer(ctx)
: null;
const contextWindowMonitor = isHookEnabled("context-window-monitor")
? createContextWindowMonitorHook(ctx)
: null;
const sessionRecovery = isHookEnabled("session-recovery")
? createSessionRecoveryHook(ctx)
: null;
const sessionNotification = isHookEnabled("session-notification")
? createSessionNotification(ctx)
: null;
// Wire up recovery state tracking between session-recovery and todo-continuation-enforcer
// This prevents the continuation enforcer from injecting prompts during active recovery
sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering);
sessionRecovery.setOnRecoveryCompleteCallback(todoContinuationEnforcer.markRecoveryComplete);
if (sessionRecovery && todoContinuationEnforcer) {
sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering);
sessionRecovery.setOnRecoveryCompleteCallback(todoContinuationEnforcer.markRecoveryComplete);
}
const commentChecker = createCommentCheckerHooks();
const grepOutputTruncator = createGrepOutputTruncatorHook(ctx);
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
const directoryReadmeInjector = createDirectoryReadmeInjectorHook(ctx);
const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx);
const thinkMode = createThinkModeHook();
const commentChecker = isHookEnabled("comment-checker")
? createCommentCheckerHooks()
: null;
const grepOutputTruncator = isHookEnabled("grep-output-truncator")
? createGrepOutputTruncatorHook(ctx)
: null;
const directoryAgentsInjector = isHookEnabled("directory-agents-injector")
? createDirectoryAgentsInjectorHook(ctx)
: null;
const directoryReadmeInjector = isHookEnabled("directory-readme-injector")
? createDirectoryReadmeInjectorHook(ctx)
: null;
const emptyTaskResponseDetector = isHookEnabled("empty-task-response-detector")
? createEmptyTaskResponseDetectorHook(ctx)
: null;
const thinkMode = isHookEnabled("think-mode")
? createThinkModeHook()
: null;
const claudeCodeHooks = createClaudeCodeHooksHook(ctx, {
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
});
const anthropicAutoCompact = createAnthropicAutoCompactHook(ctx);
const rulesInjector = createRulesInjectorHook(ctx);
const autoUpdateChecker = createAutoUpdateCheckerHook(ctx);
const anthropicAutoCompact = isHookEnabled("anthropic-auto-compact")
? createAnthropicAutoCompactHook(ctx)
: null;
const rulesInjector = isHookEnabled("rules-injector")
? createRulesInjectorHook(ctx)
: null;
const autoUpdateChecker = isHookEnabled("auto-update-checker")
? createAutoUpdateCheckerHook(ctx)
: null;
const ultraworkMode = isHookEnabled("ultrawork-mode")
? createUltraworkModeHook()
: null;
updateTerminalTitle({ sessionId: "main" });
const backgroundManager = new BackgroundManager(ctx);
const backgroundNotificationHook = createBackgroundNotificationHook(backgroundManager);
const backgroundNotificationHook = isHookEnabled("background-notification")
? createBackgroundNotificationHook(backgroundManager)
: null;
const backgroundTools = createBackgroundTools(backgroundManager, ctx.client);
const callOmoAgent = createCallOmoAgent(ctx, backgroundManager);
@@ -189,6 +234,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
"chat.message": async (input, output) => {
await claudeCodeHooks["chat.message"]?.(input, output);
await ultraworkMode?.["chat.message"]?.(input, output);
},
config: async (config) => {
@@ -252,16 +298,18 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
},
event: async (input) => {
await autoUpdateChecker.event(input);
await autoUpdateChecker?.event(input);
await claudeCodeHooks.event(input);
await backgroundNotificationHook.event(input);
await todoContinuationEnforcer.handler(input);
await contextWindowMonitor.event(input);
await directoryAgentsInjector.event(input);
await directoryReadmeInjector.event(input);
await rulesInjector.event(input);
await thinkMode.event(input);
await anthropicAutoCompact.event(input);
await backgroundNotificationHook?.event(input);
await sessionNotification?.(input);
await todoContinuationEnforcer?.handler(input);
await contextWindowMonitor?.event(input);
await directoryAgentsInjector?.event(input);
await directoryReadmeInjector?.event(input);
await rulesInjector?.event(input);
await thinkMode?.event(input);
await anthropicAutoCompact?.event(input);
await ultraworkMode?.event(input);
const { event } = input;
const props = event.properties as Record<string, unknown> | undefined;
@@ -313,7 +361,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const sessionID = props?.sessionID as string | undefined;
const error = props?.error;
if (sessionRecovery.isRecoverableError(error)) {
if (sessionRecovery?.isRecoverableError(error)) {
const messageInfo = {
id: props?.messageID as string | undefined,
role: "assistant" as const,
@@ -359,7 +407,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
"tool.execute.before": async (input, output) => {
await claudeCodeHooks["tool.execute.before"](input, output);
await commentChecker["tool.execute.before"](input, output);
await commentChecker?.["tool.execute.before"](input, output);
if (input.sessionID === getMainSessionID()) {
updateTerminalTitle({
@@ -374,13 +422,13 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
"tool.execute.after": async (input, output) => {
await claudeCodeHooks["tool.execute.after"](input, output);
await grepOutputTruncator["tool.execute.after"](input, output);
await contextWindowMonitor["tool.execute.after"](input, output);
await commentChecker["tool.execute.after"](input, output);
await directoryAgentsInjector["tool.execute.after"](input, output);
await directoryReadmeInjector["tool.execute.after"](input, output);
await rulesInjector["tool.execute.after"](input, output);
await emptyTaskResponseDetector["tool.execute.after"](input, output);
await grepOutputTruncator?.["tool.execute.after"](input, output);
await contextWindowMonitor?.["tool.execute.after"](input, output);
await commentChecker?.["tool.execute.after"](input, output);
await directoryAgentsInjector?.["tool.execute.after"](input, output);
await directoryReadmeInjector?.["tool.execute.after"](input, output);
await rulesInjector?.["tool.execute.after"](input, output);
await emptyTaskResponseDetector?.["tool.execute.after"](input, output);
if (input.sessionID === getMainSessionID()) {
updateTerminalTitle({
@@ -402,4 +450,5 @@ export type {
AgentOverrideConfig,
AgentOverrides,
McpName,
HookName,
} from "./config";

View File

@@ -62,21 +62,51 @@ function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text
return text.slice(0, maxLength) + "..."
}
function formatTaskStatus(task: BackgroundTask): string {
const duration = formatDuration(task.startedAt, task.completedAt)
const progress = task.progress
? `\nTool calls: ${task.progress.toolCalls}\nLast tool: ${task.progress.lastTool ?? "N/A"}`
: ""
const promptPreview = truncateText(task.prompt, 500)
let progressSection = ""
if (task.progress) {
progressSection = `\nTool calls: ${task.progress.toolCalls}\nLast tool: ${task.progress.lastTool ?? "N/A"}`
}
return `Task Status
let lastMessageSection = ""
if (task.progress?.lastMessage) {
const truncated = truncateText(task.progress.lastMessage, 500)
const messageTime = task.progress.lastMessageAt
? task.progress.lastMessageAt.toISOString()
: "N/A"
lastMessageSection = `
Task ID: ${task.id}
Description: ${task.description}
Agent: ${task.agent}
Status: ${task.status}
Duration: ${duration}${progress}
## Last Message (${messageTime})
Session ID: ${task.sessionID}`
\`\`\`
${truncated}
\`\`\``
}
return `# Task Status
| Field | Value |
|-------|-------|
| Task ID | \`${task.id}\` |
| Description | ${task.description} |
| Agent | ${task.agent} |
| Status | **${task.status}** |
| Duration | ${duration} |
| Session ID | \`${task.sessionID}\` |${progressSection}
## Original Prompt
\`\`\`
${promptPreview}
\`\`\`${lastMessageSection}`
}
async function formatTaskResult(task: BackgroundTask, client: OpencodeClient): Promise<string> {

View File

@@ -1,5 +1,5 @@
import { tool } from "@opencode-ai/plugin"
import { existsSync, readdirSync, statSync, readlinkSync, readFileSync } from "fs"
import { existsSync, readdirSync, lstatSync, readlinkSync, readFileSync } from "fs"
import { homedir } from "os"
import { join, resolve, basename } from "path"
import { z } from "zod/v4"
@@ -39,7 +39,7 @@ function discoverSkillsFromDir(
if (entry.isDirectory() || entry.isSymbolicLink()) {
let resolvedPath = skillPath
try {
const stats = statSync(skillPath, { throwIfNoEntry: false })
const stats = lstatSync(skillPath, { throwIfNoEntry: false })
if (stats?.isSymbolicLink()) {
resolvedPath = resolve(skillPath, "..", readlinkSync(skillPath))
}
@@ -85,7 +85,7 @@ const skillListForDescription = availableSkills
function resolveSymlink(skillPath: string): string {
try {
const stats = statSync(skillPath, { throwIfNoEntry: false })
const stats = lstatSync(skillPath, { throwIfNoEntry: false })
if (stats?.isSymbolicLink()) {
return resolve(skillPath, "..", readlinkSync(skillPath))
}