fix(notify): harden bundled ownership and idle suppression
This commit is contained in:
+1
-1
@@ -22,7 +22,7 @@
|
||||
"./schema.json": "./dist/oh-my-opencode.schema.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi --external zod && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun build src/bundled-opencode-notify/index.ts --outdir dist/opencode-notify --target bun --format esm --external @ast-grep/napi --external zod && bun run build:bundled-notify && bun run build:schema",
|
||||
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi --external zod && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun build src/bundled-opencode-notify/index.ts --outdir dist/opencode-notify --target bun --format esm --external @ast-grep/napi --external zod && bun build src/shared/bundled-notify-ownership.ts --outdir dist/shared --target bun --format esm --external @ast-grep/napi --external zod && bun run build:bundled-notify && bun run build:schema",
|
||||
"build:all": "bun run build && bun run build:binaries",
|
||||
"build:binaries": "bun run script/build-binaries.ts",
|
||||
"build:schema": "bun run script/build-schema.ts",
|
||||
|
||||
@@ -6,6 +6,11 @@ interface TodoItem {
|
||||
status: string
|
||||
}
|
||||
|
||||
type TodoResponseMode = {
|
||||
todos?: TodoItem[]
|
||||
throwError?: boolean
|
||||
}
|
||||
|
||||
function createMockShellExecutor(notificationCommands: string[]) {
|
||||
return (cmd: TemplateStringsArray | string, ...values: unknown[]) => {
|
||||
const command = typeof cmd === "string"
|
||||
@@ -35,12 +40,18 @@ function createMockShellExecutor(notificationCommands: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function createPluginInput(todos: TodoItem[], notificationCommands: string[]) {
|
||||
function createPluginInput(todoMode: TodoResponseMode, notificationCommands: string[]) {
|
||||
return {
|
||||
$: createMockShellExecutor(notificationCommands),
|
||||
client: {
|
||||
session: {
|
||||
todo: async () => ({ data: todos }),
|
||||
todo: async () => {
|
||||
if (todoMode.throwError) {
|
||||
throw new Error("todo fetch failed")
|
||||
}
|
||||
|
||||
return { data: todoMode.todos ?? [] }
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof bundledNotifyPlugin.server>[0]
|
||||
@@ -60,7 +71,7 @@ describe("bundled-opencode-notify idle suppression", () => {
|
||||
// given
|
||||
const notificationCommands: string[] = []
|
||||
const hooks = await bundledNotifyPlugin.server(
|
||||
createPluginInput([{ status: "in_progress" }], notificationCommands),
|
||||
createPluginInput({ todos: [{ status: "in_progress" }] }, notificationCommands),
|
||||
)
|
||||
|
||||
// when
|
||||
@@ -77,7 +88,7 @@ describe("bundled-opencode-notify idle suppression", () => {
|
||||
// given
|
||||
const notificationCommands: string[] = []
|
||||
const hooks = await bundledNotifyPlugin.server(
|
||||
createPluginInput([{ status: "completed" }], notificationCommands),
|
||||
createPluginInput({ todos: [{ status: "completed" }] }, notificationCommands),
|
||||
)
|
||||
|
||||
// when
|
||||
@@ -94,10 +105,12 @@ describe("bundled-opencode-notify idle suppression", () => {
|
||||
// given
|
||||
const notificationCommands: string[] = []
|
||||
const hooks = await bundledNotifyPlugin.server(
|
||||
createPluginInput([
|
||||
{ status: "blocked" },
|
||||
{ status: "deleted" },
|
||||
], notificationCommands),
|
||||
createPluginInput({
|
||||
todos: [
|
||||
{ status: "blocked" },
|
||||
{ status: "deleted" },
|
||||
],
|
||||
}, notificationCommands),
|
||||
)
|
||||
|
||||
// when
|
||||
@@ -109,4 +122,21 @@ describe("bundled-opencode-notify idle suppression", () => {
|
||||
// then
|
||||
expect(notificationCommands.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("suppresses ready notification when todo fetch state is unknown", async () => {
|
||||
// given
|
||||
const notificationCommands: string[] = []
|
||||
const hooks = await bundledNotifyPlugin.server(
|
||||
createPluginInput({ throwError: true }, notificationCommands),
|
||||
)
|
||||
|
||||
// when
|
||||
await hooks.event?.({ event: { type: "session.idle", properties: { sessionID: "session-4" } } })
|
||||
jest.advanceTimersByTime(1500)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// then
|
||||
expect(notificationCommands).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Hooks, Plugin, PluginModule } from "@opencode-ai/plugin"
|
||||
import { hasIncompleteTodos } from "../hooks/session-todo-status"
|
||||
import { getSessionTodoState } from "../hooks/session-todo-status"
|
||||
|
||||
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
||||
|
||||
@@ -82,8 +82,8 @@ async function sendSessionNotification(ctx: Parameters<Plugin>[0], title: string
|
||||
}
|
||||
|
||||
async function sendIdleReadyNotification(ctx: Parameters<Plugin>[0], sessionID: string): Promise<void> {
|
||||
const hasPendingTodos = await hasIncompleteTodos(ctx, sessionID)
|
||||
if (hasPendingTodos) return
|
||||
const todoState = await getSessionTodoState(ctx, sessionID)
|
||||
if (todoState !== "clear") return
|
||||
await sendSessionNotification(ctx, "OpenCode", "Agent is ready for input")
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,19 @@ interface Todo {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export async function hasIncompleteTodos(ctx: PluginInput, sessionID: string): Promise<boolean> {
|
||||
export type SessionTodoState = "pending" | "clear" | "unknown"
|
||||
|
||||
export async function getSessionTodoState(ctx: PluginInput, sessionID: string): Promise<SessionTodoState> {
|
||||
try {
|
||||
const response = await ctx.client.session.todo({ path: { id: sessionID } })
|
||||
const todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true })
|
||||
if (!todos || todos.length === 0) return false
|
||||
return getIncompleteCount(todos) > 0
|
||||
if (!todos || todos.length === 0) return "clear"
|
||||
return getIncompleteCount(todos) > 0 ? "pending" : "clear"
|
||||
} catch {
|
||||
return false
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasIncompleteTodos(ctx: PluginInput, sessionID: string): Promise<boolean> {
|
||||
return (await getSessionTodoState(ctx, sessionID)) === "pending"
|
||||
}
|
||||
|
||||
@@ -98,6 +98,35 @@ describe("ensureBundledNotifyOwnership", () => {
|
||||
expect(readConfig(userConfigPath).plugin).toEqual(["oh-my-openagent", canonicalEntry])
|
||||
})
|
||||
|
||||
test("does not classify unrelated notify-like plugin names as unsafe", () => {
|
||||
// given
|
||||
const userConfigPath = join(userConfigDir, "opencode.json")
|
||||
writeFileSync(userConfigPath, JSON.stringify({ plugin: ["team-notify-center", "oh-my-openagent"] }, null, 2) + "\n")
|
||||
|
||||
// when
|
||||
const result = ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot })
|
||||
|
||||
// then
|
||||
expect(result.changedUserConfig).toBe(true)
|
||||
expect(readConfig(userConfigPath).plugin).toEqual(["team-notify-center", "oh-my-openagent", canonicalEntry])
|
||||
})
|
||||
|
||||
test("migrates stale bundled dist/opencode-notify file URL to canonical bundled entry", () => {
|
||||
// given
|
||||
const userConfigPath = join(userConfigDir, "opencode.json")
|
||||
const stalePackageRoot = join(rootDir, "package-old")
|
||||
mkdirSync(join(stalePackageRoot, "dist", "opencode-notify"), { recursive: true })
|
||||
const staleBundledEntry = getBundledNotifyCanonicalEntry(stalePackageRoot)
|
||||
writeFileSync(userConfigPath, JSON.stringify({ plugin: [staleBundledEntry, "oh-my-openagent"] }, null, 2) + "\n")
|
||||
|
||||
// when
|
||||
const result = ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot })
|
||||
|
||||
// then
|
||||
expect(result.changedUserConfig).toBe(true)
|
||||
expect(readConfig(userConfigPath).plugin).toEqual(["oh-my-openagent", canonicalEntry])
|
||||
})
|
||||
|
||||
test("removes project recognized notify and adds bundled user owner", () => {
|
||||
// given
|
||||
const projectConfigPath = join(projectDir, ".opencode", "opencode.json")
|
||||
|
||||
@@ -66,11 +66,41 @@ function isRecognizedExternalNotifyId(entry: string): boolean {
|
||||
return KNOWN_EXTERNAL_NOTIFY_IDS.some((base) => normalized === base || normalized.startsWith(`${base}@`))
|
||||
}
|
||||
|
||||
function looksLikeNotifyPlugin(entry: string): boolean {
|
||||
const normalized = entry.trim().toLowerCase()
|
||||
return normalized.includes("kdco/notify")
|
||||
|| normalized.includes("opencode-notify")
|
||||
|| normalized.includes("notify")
|
||||
function normalizePathForComparison(pathValue: string): string {
|
||||
return resolve(pathValue).replace(/\\/g, "/").replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function tryParseFileUrlPath(entry: string): string | null {
|
||||
if (!entry.startsWith("file://")) return null
|
||||
|
||||
try {
|
||||
return fileURLToPath(entry)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function hasBundledNotifyArtifactPathShape(pathValue: string): boolean {
|
||||
const normalizedPath = normalizePathForComparison(pathValue)
|
||||
return normalizedPath.endsWith("/dist/opencode-notify")
|
||||
}
|
||||
|
||||
function isBundledNotifyArtifactEntry(entry: string, canonicalEntry: string): boolean {
|
||||
if (entry === canonicalEntry) return true
|
||||
|
||||
const filePath = tryParseFileUrlPath(entry)
|
||||
if (!filePath) return false
|
||||
|
||||
return hasBundledNotifyArtifactPathShape(filePath)
|
||||
}
|
||||
|
||||
function isPathBasedNotifyEntry(entry: string): boolean {
|
||||
if (!isPathLikePluginEntry(entry)) return false
|
||||
|
||||
const filePath = tryParseFileUrlPath(entry)
|
||||
const pathCandidate = filePath ?? entry
|
||||
const normalizedPath = normalizePathForComparison(pathCandidate).toLowerCase()
|
||||
return normalizedPath.includes("/opencode-notify")
|
||||
}
|
||||
|
||||
function areTupleOptionsEmptyOrDefault(options: unknown[]): boolean {
|
||||
@@ -86,7 +116,7 @@ function areTupleOptionsEmptyOrDefault(options: unknown[]): boolean {
|
||||
|
||||
function classifyPluginEntry(entry: OpenCodePluginEntry, index: number, canonicalEntry: string): ClassifiedEntry {
|
||||
if (typeof entry === "string") {
|
||||
if (entry === canonicalEntry) {
|
||||
if (isBundledNotifyArtifactEntry(entry, canonicalEntry)) {
|
||||
return { kind: "bundled", entry, index }
|
||||
}
|
||||
|
||||
@@ -94,11 +124,7 @@ function classifyPluginEntry(entry: OpenCodePluginEntry, index: number, canonica
|
||||
return { kind: "recognized-external", entry, index }
|
||||
}
|
||||
|
||||
if (!looksLikeNotifyPlugin(entry)) {
|
||||
return { kind: "other", entry, index }
|
||||
}
|
||||
|
||||
if (isPathLikePluginEntry(entry)) {
|
||||
if (isPathBasedNotifyEntry(entry)) {
|
||||
return {
|
||||
kind: "unsafe-external",
|
||||
entry,
|
||||
@@ -107,16 +133,11 @@ function classifyPluginEntry(entry: OpenCodePluginEntry, index: number, canonica
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "unsafe-external",
|
||||
entry,
|
||||
index,
|
||||
reason: "notify plugin entry is not an exact recognized kdco/notify identifier",
|
||||
}
|
||||
return { kind: "other", entry, index }
|
||||
}
|
||||
|
||||
const [tupleKey, ...tupleOptions] = entry
|
||||
if (tupleKey === canonicalEntry) {
|
||||
if (isBundledNotifyArtifactEntry(tupleKey, canonicalEntry)) {
|
||||
if (areTupleOptionsEmptyOrDefault(tupleOptions)) {
|
||||
return { kind: "bundled", entry, index }
|
||||
}
|
||||
@@ -142,12 +163,12 @@ function classifyPluginEntry(entry: OpenCodePluginEntry, index: number, canonica
|
||||
}
|
||||
}
|
||||
|
||||
if (looksLikeNotifyPlugin(tupleKey)) {
|
||||
if (isPathBasedNotifyEntry(tupleKey)) {
|
||||
return {
|
||||
kind: "unsafe-external",
|
||||
entry,
|
||||
index,
|
||||
reason: "tuple-based notify entry is not an exact recognized kdco/notify identifier",
|
||||
reason: "path-based notify plugin entries are not auto-migrated",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { resolve } from "node:path"
|
||||
|
||||
describe("bundled notify postinstall bootstrap path", () => {
|
||||
test("build script emits ownership module consumed by postinstall", () => {
|
||||
// given
|
||||
const packageJsonPath = resolve(import.meta.dir, "..", "..", "package.json")
|
||||
const postinstallPath = resolve(import.meta.dir, "..", "..", "postinstall.mjs")
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
|
||||
scripts?: { build?: string }
|
||||
}
|
||||
const postinstallScript = readFileSync(postinstallPath, "utf-8")
|
||||
|
||||
// when
|
||||
const buildScript = packageJson.scripts?.build ?? ""
|
||||
|
||||
// then
|
||||
expect(buildScript).toContain("src/shared/bundled-notify-ownership.ts --outdir dist/shared")
|
||||
expect(postinstallScript).toContain("dist/shared/bundled-notify-ownership.js")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user