Compare commits

..

4 Commits

Author SHA1 Message Date
github-actions[bot]
8db2bd3893 release: v0.4.2 2025-12-13 03:16:48 +00:00
YeonGyu-Kim
555abbc0d6 fix(google-auth): integrate into main package via config option
Fixes #30. OpenCode's plugin loader treats subpath exports like
"oh-my-opencode/google-auth" as separate npm packages, causing
BunInstallFailedError.

Solution: Enable Google auth via `google_auth: true` in
oh-my-opencode.json instead of a separate subpath plugin.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-13 12:14:51 +09:00
Junho Yeo
3b129f11c4 fix(config): deep merge agent overrides with reusable deepMerge utility (#27) 2025-12-13 12:00:38 +09:00
Junho Yeo
2cab36f06d fix(hooks): prevent infinite loop when todo-continuation-enforcer runs during session recovery (#29) 2025-12-13 11:48:22 +09:00
11 changed files with 135 additions and 42 deletions

View File

@@ -43,7 +43,7 @@ OpenCode 가 낭만이 사라진것같은 오늘날의 시대에, 당신에게
- 이 플러그인은 [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, Anthropic 의 모델을 사용합니다.
- Anthropic 모델들을 사용하기 위해 [OpenCode 의 내장 Claude Code Max Plan 로그인 기능](https://opencode.ai/docs/providers/#anthropic)을 사용하세요.
- OpenAI 모델 (ChatGPT Plus/Pro)을 사용하기 위해 [OpenCode-OpenAI-Codex-Auth 플러그인](https://github.com/numman-ali/opencode-openai-codex-auth)을 설치하세요.
- Google Gemini 모델을 위해 `oh-my-opencode/google-auth` 플러그인을 추가하세요 (**내장 Antigravity OAuth**).
- Google Gemini 모델을 위해 `oh-my-opencode.json`에서 `google_auth: true`를 활성화하세요 (**내장 Antigravity OAuth**).
- 다른 프로바이더를 위해 [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) 또는 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)를 대안으로 사용할 수 있습니다.
- **사실 밑의 내용은 읽지 않아도 됩니다. 그냥 설치하시면 됩니다. 모델 설정하고나면 추가로 공부하거나 알 필요가 없고, 설치하고 나면 그냥 더 좋아집니다.**
- **이 문서도 그냥 Claude Code, OpenCode, Cursor에 붙여넣고, 설치하고, 사용자의 개입이 필요할 때 알려달라고 프롬프팅하세요. 그럼 그냥 깔립니다.**
@@ -123,14 +123,11 @@ opencode auth login
#### 4.2 Google Gemini (내장 Antigravity OAuth)
먼저 google-auth 플러그인을 추가:
먼저 `~/.config/opencode/oh-my-opencode.json` (또는 프로젝트 레벨의 `.opencode/oh-my-opencode.json`)에서 Google auth를 활성화:
```json
{
"plugin": [
"oh-my-opencode",
"oh-my-opencode/google-auth"
]
"google_auth": true
}
```

View File

@@ -42,7 +42,7 @@ Even if you don't, invest a little time. Boost your skills and productivity by m
- This plugin utilizes models from [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, and Anthropic.
- For Anthropic models, use [OpenCode's built-in Claude Code Max Plan login](https://opencode.ai/docs/providers/#anthropic).
- For OpenAI models (ChatGPT Plus/Pro), install the [OpenCode-OpenAI-Codex-Auth plugin](https://github.com/numman-ali/opencode-openai-codex-auth).
- For Google Gemini models, add `oh-my-opencode/google-auth` plugin for **built-in Antigravity OAuth**.
- For Google Gemini models, enable `google_auth: true` in `oh-my-opencode.json` for **built-in Antigravity OAuth**.
- For other providers, [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) or [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) remain available as alternatives.
- **Truth be told, the rest is optional. Just install it. Once set up, no further learning is required. It simply evolves.**
- **Paste this document into Claude Code, OpenCode, or Cursor, instruct it to install, and ask for assistance if needed. It just installs.**
@@ -122,14 +122,11 @@ opencode auth login
#### 4.2 Google Gemini (Built-in Antigravity OAuth)
First, add the google-auth plugin:
First, enable Google auth in `~/.config/opencode/oh-my-opencode.json` (or `.opencode/oh-my-opencode.json` for project-level):
```json
{
"plugin": [
"oh-my-opencode",
"oh-my-opencode/google-auth"
]
"google_auth": true
}
```

View File

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

View File

@@ -5,6 +5,7 @@ import { librarianAgent } from "./librarian"
import { exploreAgent } from "./explore"
import { frontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer"
import { documentWriterAgent } from "./document-writer"
import { deepMerge } from "../shared"
const allBuiltinAgents: Record<AgentName, AgentConfig> = {
oracle: oracleAgent,
@@ -18,16 +19,7 @@ function mergeAgentConfig(
base: AgentConfig,
override: AgentOverrideConfig
): AgentConfig {
return {
...base,
...override,
tools: override.tools !== undefined
? { ...(base.tools ?? {}), ...override.tools }
: base.tools,
permission: override.permission !== undefined
? { ...(base.permission ?? {}), ...override.permission }
: base.permission,
}
return deepMerge(base, override as Partial<AgentConfig>)
}
export function createBuiltinAgents(

View File

@@ -64,6 +64,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
disabled_agents: z.array(AgentNameSchema).optional(),
agents: AgentOverridesSchema.optional(),
claude_code: ClaudeCodeConfigSchema.optional(),
google_auth: z.boolean().optional(),
})
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>

View File

@@ -1,7 +1,7 @@
export { createTodoContinuationEnforcer } from "./todo-continuation-enforcer";
export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer";
export { createContextWindowMonitorHook } from "./context-window-monitor";
export { createSessionNotification } from "./session-notification";
export { createSessionRecoveryHook } from "./session-recovery";
export { createSessionRecoveryHook, type SessionRecoveryHook } from "./session-recovery";
export { createCommentCheckerHooks } from "./comment-checker";
export { createGrepOutputTruncatorHook } from "./grep-output-truncator";
export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";

View File

@@ -216,14 +216,26 @@ async function recoverEmptyContentMessage(
// All error types have dedicated recovery functions (recoverToolResultMissing,
// recoverThinkingBlockOrder, recoverThinkingDisabledViolation, recoverEmptyContentMessage).
export function createSessionRecoveryHook(ctx: PluginInput) {
export interface SessionRecoveryHook {
handleSessionRecovery: (info: MessageInfo) => Promise<boolean>
isRecoverableError: (error: unknown) => boolean
setOnAbortCallback: (callback: (sessionID: string) => void) => void
setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void
}
export function createSessionRecoveryHook(ctx: PluginInput): SessionRecoveryHook {
const processingErrors = new Set<string>()
let onAbortCallback: ((sessionID: string) => void) | null = null
let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null
const setOnAbortCallback = (callback: (sessionID: string) => void): void => {
onAbortCallback = callback
}
const setOnRecoveryCompleteCallback = (callback: (sessionID: string) => void): void => {
onRecoveryCompleteCallback = callback
}
const isRecoverableError = (error: unknown): boolean => {
return detectErrorType(error) !== null
}
@@ -242,12 +254,12 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
processingErrors.add(assistantMsgID)
try {
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
if (onAbortCallback) {
onAbortCallback(sessionID)
onAbortCallback(sessionID) // Mark recovering BEFORE abort
}
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
@@ -301,6 +313,11 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
return false
} finally {
processingErrors.delete(assistantMsgID)
// Always notify recovery complete, regardless of success or failure
if (sessionID && onRecoveryCompleteCallback) {
onRecoveryCompleteCallback(sessionID)
}
}
}
@@ -308,5 +325,6 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
handleSessionRecovery,
isRecoverableError,
setOnAbortCallback,
setOnRecoveryCompleteCallback,
}
}

View File

@@ -1,5 +1,11 @@
import type { PluginInput } from "@opencode-ai/plugin"
export interface TodoContinuationEnforcer {
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
markRecovering: (sessionID: string) => void
markRecoveryComplete: (sessionID: string) => void
}
interface Todo {
content: string
status: string
@@ -32,13 +38,22 @@ function detectInterrupt(error: unknown): boolean {
return false
}
export function createTodoContinuationEnforcer(ctx: PluginInput) {
export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuationEnforcer {
const remindedSessions = new Set<string>()
const interruptedSessions = new Set<string>()
const errorSessions = new Set<string>()
const recoveringSessions = new Set<string>()
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
const markRecovering = (sessionID: string): void => {
recoveringSessions.add(sessionID)
}
const markRecoveryComplete = (sessionID: string): void => {
recoveringSessions.delete(sessionID)
}
const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.error") {
@@ -73,6 +88,11 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
const timer = setTimeout(async () => {
pendingTimers.delete(sessionID)
// Check if session is in recovery mode - if so, skip entirely without clearing state
if (recoveringSessions.has(sessionID)) {
return
}
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
interruptedSessions.delete(sessionID)
@@ -111,7 +131,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
remindedSessions.add(sessionID)
// Re-check if abort occurred during the delay/fetch
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) {
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID) || recoveringSessions.has(sessionID)) {
remindedSessions.delete(sessionID)
return
}
@@ -158,6 +178,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
remindedSessions.delete(sessionInfo.id)
interruptedSessions.delete(sessionInfo.id)
errorSessions.delete(sessionInfo.id)
recoveringSessions.delete(sessionInfo.id)
// Cancel pending continuation
const timer = pendingTimers.get(sessionInfo.id)
@@ -168,4 +189,10 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
}
}
}
return {
handler,
markRecovering,
markRecoveryComplete,
}
}

View File

@@ -16,6 +16,7 @@ import {
createBackgroundNotificationHook,
createAutoUpdateCheckerHook,
} from "./hooks";
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
import {
loadUserCommands,
loadProjectCommands,
@@ -42,7 +43,7 @@ import { builtinTools, createCallOmoAgent, createBackgroundTools } from "./tools
import { BackgroundManager } from "./features/background-agent";
import { createBuiltinMcps } from "./mcp";
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
import { log } from "./shared/logger";
import { log, deepMerge } from "./shared";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
@@ -89,10 +90,7 @@ function mergeConfigs(
return {
...base,
...override,
agents:
override.agents !== undefined
? { ...(base.agents ?? {}), ...override.agents }
: base.agents,
agents: deepMerge(base.agents, override.agents),
disabled_agents: [
...new Set([
...(base.disabled_agents ?? []),
@@ -105,10 +103,7 @@ function mergeConfigs(
...(override.disabled_mcps ?? []),
]),
],
claude_code:
override.claude_code !== undefined || base.claude_code !== undefined
? { ...(base.claude_code ?? {}), ...(override.claude_code ?? {}) }
: undefined,
claude_code: deepMerge(base.claude_code, override.claude_code),
};
}
@@ -151,6 +146,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx);
const contextWindowMonitor = createContextWindowMonitorHook(ctx);
const sessionRecovery = createSessionRecoveryHook(ctx);
// 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);
const commentChecker = createCommentCheckerHooks();
const grepOutputTruncator = createGrepOutputTruncatorHook(ctx);
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
@@ -173,7 +174,13 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const callOmoAgent = createCallOmoAgent(ctx, backgroundManager);
const googleAuthHooks = pluginConfig.google_auth
? await createGoogleAntigravityAuthPlugin(ctx)
: null;
return {
...(googleAuthHooks ? { auth: googleAuthHooks.auth } : {}),
tool: {
...builtinTools,
...backgroundTools,
@@ -248,7 +255,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
await autoUpdateChecker.event(input);
await claudeCodeHooks.event(input);
await backgroundNotificationHook.event(input);
await todoContinuationEnforcer(input);
await todoContinuationEnforcer.handler(input);
await contextWindowMonitor.event(input);
await directoryAgentsInjector.event(input);
await directoryReadmeInjector.event(input);

53
src/shared/deep-merge.ts Normal file
View File

@@ -0,0 +1,53 @@
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
const MAX_DEPTH = 50;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}
/**
* Deep merges two objects, with override values taking precedence.
* - Objects are recursively merged
* - Arrays are replaced (not concatenated)
* - undefined values in override do not overwrite base values
*
* @example
* deepMerge({ a: 1, b: { c: 2, d: 3 } }, { b: { c: 10 }, e: 5 })
* // => { a: 1, b: { c: 10, d: 3 }, e: 5 }
*/
export function deepMerge<T extends Record<string, unknown>>(base: T, override: Partial<T>, depth?: number): T;
export function deepMerge<T extends Record<string, unknown>>(base: T | undefined, override: T | undefined, depth?: number): T | undefined;
export function deepMerge<T extends Record<string, unknown>>(
base: T | undefined,
override: T | undefined,
depth = 0
): T | undefined {
if (!base && !override) return undefined;
if (!base) return override;
if (!override) return base;
if (depth > MAX_DEPTH) return override ?? base;
const result = { ...base } as Record<string, unknown>;
for (const key of Object.keys(override)) {
if (DANGEROUS_KEYS.has(key)) continue;
const baseValue = base[key];
const overrideValue = override[key];
if (overrideValue === undefined) continue;
if (isPlainObject(baseValue) && isPlainObject(overrideValue)) {
result[key] = deepMerge(baseValue, overrideValue, depth + 1);
} else {
result[key] = overrideValue;
}
}
return result as T;
}

View File

@@ -7,3 +7,4 @@ export * from "./snake-case"
export * from "./tool-name"
export * from "./pattern-matcher"
export * from "./hook-disabled"
export * from "./deep-merge"