diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8f95aeae4..f22c81ce4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -213,8 +213,8 @@ jobs: fi npm publish --access public --provenance $TAG_ARG env: + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} NPM_CONFIG_PROVENANCE: true - - name: Publish oh-my-openagent if: steps.check.outputs.skip != 'true' run: | @@ -242,8 +242,8 @@ jobs: fi npm publish --access public --provenance $TAG_ARG || echo "oh-my-openagent publish may have failed (package may already exist)" env: + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} NPM_CONFIG_PROVENANCE: true - - name: Restore package.json if: steps.check.outputs.skip != 'true' run: | diff --git a/signatures/cla.json b/signatures/cla.json index 518dd4e69..8633fbd58 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -2015,6 +2015,14 @@ "created_at": "2026-03-07T13:53:56Z", "repoId": 1108837393, "pullRequestNo": 2360 + }, + { + "name": "crazyrabbit0", + "id": 5244848, + "comment_id": 3936744393, + "created_at": "2026-02-20T19:40:05Z", + "repoId": 1108837393, + "pullRequestNo": 2012 } ] } \ No newline at end of file diff --git a/src/hooks/auto-update-checker/cache.ts b/src/hooks/auto-update-checker/cache.ts index 93778fedb..14853e78f 100644 --- a/src/hooks/auto-update-checker/cache.ts +++ b/src/hooks/auto-update-checker/cache.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs" import * as path from "node:path" -import { CACHE_DIR, PACKAGE_NAME } from "./constants" +import { CACHE_DIR, PACKAGE_NAME, USER_CONFIG_DIR } from "./constants" import { log } from "../../shared/logger" interface BunLockfile { @@ -23,7 +23,7 @@ function removeFromBunLock(packageName: string): boolean { try { const content = fs.readFileSync(lockPath, "utf-8") const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile - let modified = false + let modified = false if (lock.packages?.[packageName]) { delete lock.packages[packageName] @@ -43,15 +43,20 @@ function removeFromBunLock(packageName: string): boolean { export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean { try { - const pkgDir = path.join(CACHE_DIR, "node_modules", packageName) + const pkgDirs = [ + path.join(USER_CONFIG_DIR, "node_modules", packageName), + path.join(CACHE_DIR, "node_modules", packageName), + ] let packageRemoved = false let lockRemoved = false - if (fs.existsSync(pkgDir)) { - fs.rmSync(pkgDir, { recursive: true, force: true }) - log(`[auto-update-checker] Package removed: ${pkgDir}`) - packageRemoved = true + for (const pkgDir of pkgDirs) { + if (fs.existsSync(pkgDir)) { + fs.rmSync(pkgDir, { recursive: true, force: true }) + log(`[auto-update-checker] Package removed: ${pkgDir}`) + packageRemoved = true + } } lockRemoved = removeFromBunLock(packageName) diff --git a/src/hooks/session-notification-scheduler.ts b/src/hooks/session-notification-scheduler.ts index d28abd112..367ca5dcb 100644 --- a/src/hooks/session-notification-scheduler.ts +++ b/src/hooks/session-notification-scheduler.ts @@ -9,6 +9,8 @@ type SessionNotificationConfig = { idleConfirmationDelay: number skipIfIncompleteTodos: boolean maxTrackedSessions: number + /** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */ + activityGracePeriodMs?: number } export function createIdleNotificationScheduler(options: { @@ -24,6 +26,9 @@ export function createIdleNotificationScheduler(options: { const sessionActivitySinceIdle = new Set() const notificationVersions = new Map() const executingNotifications = new Set() + const scheduledAt = new Map() + + const activityGracePeriodMs = options.config.activityGracePeriodMs ?? 100 function cleanupOldSessions(): void { const maxSessions = options.config.maxTrackedSessions @@ -43,6 +48,10 @@ export function createIdleNotificationScheduler(options: { const sessionsToRemove = Array.from(executingNotifications).slice(0, executingNotifications.size - maxSessions) sessionsToRemove.forEach((id) => executingNotifications.delete(id)) } + if (scheduledAt.size > maxSessions) { + const sessionsToRemove = Array.from(scheduledAt.keys()).slice(0, scheduledAt.size - maxSessions) + sessionsToRemove.forEach((id) => scheduledAt.delete(id)) + } } function cancelPendingNotification(sessionID: string): void { @@ -51,11 +60,17 @@ export function createIdleNotificationScheduler(options: { clearTimeout(timer) pendingTimers.delete(sessionID) } + scheduledAt.delete(sessionID) sessionActivitySinceIdle.add(sessionID) notificationVersions.set(sessionID, (notificationVersions.get(sessionID) ?? 0) + 1) } function markSessionActivity(sessionID: string): void { + const scheduledTime = scheduledAt.get(sessionID) + if (scheduledTime && Date.now() - scheduledTime < activityGracePeriodMs) { + return + } + cancelPendingNotification(sessionID) if (!executingNotifications.has(sessionID)) { notifiedSessions.delete(sessionID) @@ -65,22 +80,26 @@ export function createIdleNotificationScheduler(options: { async function executeNotification(sessionID: string, version: number): Promise { if (executingNotifications.has(sessionID)) { pendingTimers.delete(sessionID) + scheduledAt.delete(sessionID) return } if (notificationVersions.get(sessionID) !== version) { pendingTimers.delete(sessionID) + scheduledAt.delete(sessionID) return } if (sessionActivitySinceIdle.has(sessionID)) { sessionActivitySinceIdle.delete(sessionID) pendingTimers.delete(sessionID) + scheduledAt.delete(sessionID) return } if (notifiedSessions.has(sessionID)) { pendingTimers.delete(sessionID) + scheduledAt.delete(sessionID) return } @@ -113,6 +132,7 @@ export function createIdleNotificationScheduler(options: { } finally { executingNotifications.delete(sessionID) pendingTimers.delete(sessionID) + scheduledAt.delete(sessionID) if (sessionActivitySinceIdle.has(sessionID)) { notifiedSessions.delete(sessionID) sessionActivitySinceIdle.delete(sessionID) @@ -126,6 +146,7 @@ export function createIdleNotificationScheduler(options: { if (executingNotifications.has(sessionID)) return sessionActivitySinceIdle.delete(sessionID) + scheduledAt.set(sessionID, Date.now()) const currentVersion = (notificationVersions.get(sessionID) ?? 0) + 1 notificationVersions.set(sessionID, currentVersion) @@ -144,6 +165,7 @@ export function createIdleNotificationScheduler(options: { sessionActivitySinceIdle.delete(sessionID) notificationVersions.delete(sessionID) executingNotifications.delete(sessionID) + scheduledAt.delete(sessionID) } return { diff --git a/src/hooks/session-notification.test.ts b/src/hooks/session-notification.test.ts index 8ab8933ce..d681d1668 100644 --- a/src/hooks/session-notification.test.ts +++ b/src/hooks/session-notification.test.ts @@ -195,8 +195,9 @@ describe("session-notification", () => { setMainSession(mainSessionID) const hook = createSessionNotification(createMockPluginInput(), { - idleConfirmationDelay: 100, // Long delay + idleConfirmationDelay: 100, skipIfIncompleteTodos: false, + activityGracePeriodMs: 0, }) // when - session goes idle @@ -272,6 +273,7 @@ describe("session-notification", () => { const hook = createSessionNotification(createMockPluginInput(), { idleConfirmationDelay: 50, skipIfIncompleteTodos: false, + activityGracePeriodMs: 0, }) // when - session goes idle, then message.updated fires @@ -306,6 +308,7 @@ describe("session-notification", () => { const hook = createSessionNotification(createMockPluginInput(), { idleConfirmationDelay: 50, skipIfIncompleteTodos: false, + activityGracePeriodMs: 0, }) // when - session goes idle, then tool.execute.before fires @@ -509,4 +512,75 @@ describe("session-notification", () => { } } }) + + test("should ignore activity events within grace period", async () => { + // given - main session is set + const mainSessionID = "main-grace" + setMainSession(mainSessionID) + + const hook = createSessionNotification(createMockPluginInput(), { + idleConfirmationDelay: 50, + skipIfIncompleteTodos: false, + activityGracePeriodMs: 100, + }) + + // when - session goes idle + await hook({ + event: { + type: "session.idle", + properties: { sessionID: mainSessionID }, + }, + }) + + // when - activity happens immediately (within grace period) + await hook({ + event: { + type: "tool.execute.before", + properties: { sessionID: mainSessionID }, + }, + }) + + // Wait for idle delay to pass + await new Promise((resolve) => setTimeout(resolve, 100)) + + // then - notification SHOULD be sent (activity was within grace period, ignored) + expect(notificationCalls.length).toBeGreaterThanOrEqual(1) + }) + + test("should cancel notification for activity after grace period", async () => { + // given - main session is set + const mainSessionID = "main-grace-cancel" + setMainSession(mainSessionID) + + const hook = createSessionNotification(createMockPluginInput(), { + idleConfirmationDelay: 200, + skipIfIncompleteTodos: false, + activityGracePeriodMs: 50, + }) + + // when - session goes idle + await hook({ + event: { + type: "session.idle", + properties: { sessionID: mainSessionID }, + }, + }) + + // when - wait for grace period to pass + await new Promise((resolve) => setTimeout(resolve, 60)) + + // when - activity happens after grace period + await hook({ + event: { + type: "tool.execute.before", + properties: { sessionID: mainSessionID }, + }, + }) + + // Wait for original delay to pass + await new Promise((resolve) => setTimeout(resolve, 200)) + + // then - notification should NOT be sent (activity cancelled it after grace period) + expect(notificationCalls).toHaveLength(0) + }) }) diff --git a/src/hooks/session-notification.ts b/src/hooks/session-notification.ts index 3b3dcc514..00e171b7b 100644 --- a/src/hooks/session-notification.ts +++ b/src/hooks/session-notification.ts @@ -24,6 +24,8 @@ interface SessionNotificationConfig { /** Maximum number of sessions to track before cleanup (default: 100) */ maxTrackedSessions?: number enforceMainSessionFilter?: boolean + /** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */ + activityGracePeriodMs?: number } export function createSessionNotification( ctx: PluginInput, diff --git a/src/plugin-handlers/tool-config-handler.test.ts b/src/plugin-handlers/tool-config-handler.test.ts index 4ba70497a..0ef60d56f 100644 --- a/src/plugin-handlers/tool-config-handler.test.ts +++ b/src/plugin-handlers/tool-config-handler.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "bun:test" +import { describe, it, expect, beforeEach, afterEach } from "bun:test" import { applyToolConfig } from "./tool-config-handler" import type { OhMyOpenCodeConfig } from "../config" @@ -56,6 +56,109 @@ describe("applyToolConfig", () => { }) }) + describe("#given OPENCODE_CONFIG_CONTENT has question set to deny", () => { + let originalConfigContent: string | undefined + let originalCliRunMode: string | undefined + + beforeEach(() => { + originalConfigContent = process.env.OPENCODE_CONFIG_CONTENT + originalCliRunMode = process.env.OPENCODE_CLI_RUN_MODE + }) + + afterEach(() => { + if (originalConfigContent === undefined) { + delete process.env.OPENCODE_CONFIG_CONTENT + } else { + process.env.OPENCODE_CONFIG_CONTENT = originalConfigContent + } + if (originalCliRunMode === undefined) { + delete process.env.OPENCODE_CLI_RUN_MODE + } else { + process.env.OPENCODE_CLI_RUN_MODE = originalCliRunMode + } + }) + + describe("#when config explicitly denies question permission", () => { + it.each(["sisyphus", "hephaestus", "prometheus"])( + "#then should deny question for %s even without CLI_RUN_MODE", + (agentName) => { + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ + permission: { question: "deny" }, + }) + delete process.env.OPENCODE_CLI_RUN_MODE + const params = createParams({ agents: [agentName] }) + + applyToolConfig(params) + + const agent = params.agentResult[agentName] as { + permission: Record + } + expect(agent.permission.question).toBe("deny") + }, + ) + }) + + describe("#when config does not deny question permission", () => { + it.each(["sisyphus", "hephaestus", "prometheus"])( + "#then should allow question for %s in interactive mode", + (agentName) => { + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ + permission: { question: "allow" }, + }) + delete process.env.OPENCODE_CLI_RUN_MODE + const params = createParams({ agents: [agentName] }) + + applyToolConfig(params) + + const agent = params.agentResult[agentName] as { + permission: Record + } + expect(agent.permission.question).toBe("allow") + }, + ) + }) + + describe("#when CLI_RUN_MODE is true and config does not deny", () => { + it.each(["sisyphus", "hephaestus", "prometheus"])( + "#then should deny question for %s via CLI_RUN_MODE", + (agentName) => { + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ + permission: {}, + }) + process.env.OPENCODE_CLI_RUN_MODE = "true" + const params = createParams({ agents: [agentName] }) + + applyToolConfig(params) + + const agent = params.agentResult[agentName] as { + permission: Record + } + expect(agent.permission.question).toBe("deny") + }, + ) + }) + + describe("#when config deny overrides CLI_RUN_MODE allow", () => { + it.each(["sisyphus", "hephaestus", "prometheus"])( + "#then should deny question for %s when config says deny regardless of CLI_RUN_MODE", + (agentName) => { + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ + permission: { question: "deny" }, + }) + process.env.OPENCODE_CLI_RUN_MODE = "false" + const params = createParams({ agents: [agentName] }) + + applyToolConfig(params) + + const agent = params.agentResult[agentName] as { + permission: Record + } + expect(agent.permission.question).toBe("deny") + }, + ) + }) + }) + describe("#given task_system is disabled", () => { describe("#when applying tool config", () => { it.each([ diff --git a/src/plugin-handlers/tool-config-handler.ts b/src/plugin-handlers/tool-config-handler.ts index 381dbb55c..a4486677c 100644 --- a/src/plugin-handlers/tool-config-handler.ts +++ b/src/plugin-handlers/tool-config-handler.ts @@ -3,6 +3,17 @@ import { getAgentDisplayName } from "../shared/agent-display-names"; type AgentWithPermission = { permission?: Record }; +function getConfigQuestionPermission(): string | null { + const configContent = process.env.OPENCODE_CONFIG_CONTENT; + if (!configContent) return null; + try { + const parsed = JSON.parse(configContent); + return parsed?.permission?.question ?? null; + } catch { + return null; + } +} + function agentByKey(agentResult: Record, key: string): AgentWithPermission | undefined { return (agentResult[key] ?? agentResult[getAgentDisplayName(key)]) as | AgentWithPermission @@ -32,7 +43,11 @@ export function applyToolConfig(params: { }; const isCliRunMode = process.env.OPENCODE_CLI_RUN_MODE === "true"; - const questionPermission = isCliRunMode ? "deny" : "allow"; + const configQuestionPermission = getConfigQuestionPermission(); + const questionPermission = + configQuestionPermission === "deny" ? "deny" : + isCliRunMode ? "deny" : + "allow"; const librarian = agentByKey(params.agentResult, "librarian"); if (librarian) {