From d024a367168ca636db596ca3d59f111b25c0d853 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 18 Apr 2026 17:57:47 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(desktop):=20aivis=20=E3=82=BF=E3=82=B9?= =?UTF-8?q?=E3=82=AF=E5=AE=8C=E4=BA=86=E9=80=9A=E7=9F=A5=E3=81=8C=E4=BA=8C?= =?UTF-8?q?=E9=87=8D=E3=81=AB=E8=AA=AD=E3=81=BF=E4=B8=8A=E3=81=92=E3=82=89?= =?UTF-8?q?=E3=82=8C=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 原因 Codex の完了通知経路が2本存在し、1回のタスク完了に対して /hook/complete が2回叩かれていたため、notificationsEmitter から AGENT_LIFECYCLE が2回 emit され aivis が二重に再生されていた。 - ~/.codex/hooks.json の Stop hook - Codex wrapper の `--enable codex_hooks -c 'notify=[...]'` オプション ## 修正 1. Codex wrapper 側の native `notify=` 注入を除去し、 hooks.json を唯一の完了通知ソースに一本化。 2. NotificationManager / notificationsApp.listen / notificationsEmitter.on(AGENT_LIFECYCLE, ...) の初期化を MainWindow() の外に出し、app.whenReady 直後に initNotifications() で 1回だけ実行するよう整理。ウィンドウ再生成経路でもリスナーが 多重登録されない構造にしておく(将来の再発防止)。 通知音 / aivis いずれも「1回の完了 = 1回」で鳴るようになる。 正規の並列完了(複数エージェントがたまたま同時終了)は許容する方針なので、 dedupe は入れない。 --- apps/desktop/src/main/index.ts | 27 +-- .../agent-wrappers-claude-codex-opencode.ts | 11 +- .../lib/agent-setup/agent-wrappers.test.ts | 6 +- .../templates/codex-wrapper-exec.template.sh | 8 +- apps/desktop/src/main/windows/main.ts | 195 +++++++++++------- 5 files changed, 137 insertions(+), 110 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index f2b28930ddd..73068b5c95f 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -51,13 +51,16 @@ import { } from "./lib/terminal"; import { disposeTray, initTray } from "./lib/tray"; import { windowManager } from "./lib/window-manager"; -import { createWorkspaceMediaProtocolHandler } from "./lib/workspace-media-protocol"; // Lazy import to avoid module resolution issues during Vite build const loadVscodeShim = () => import("./lib/vscode-shim") as Promise; -import { cleanupMainWindowResources, MainWindow } from "./windows/main"; +import { + cleanupMainWindowResources, + initNotifications, + MainWindow, +} from "./windows/main"; console.log("[main] Local database ready:", !!localDb); const IS_DEV = process.env.NODE_ENV === "development"; @@ -552,17 +555,6 @@ protocol.registerSchemesAsPrivileged([ secure: true, bypassCSP: true, supportFetchAPI: true, - stream: true, - }, - }, - { - scheme: "superset-workspace-media", - privileges: { - standard: true, - secure: true, - bypassCSP: true, - supportFetchAPI: true, - stream: true, }, }, { @@ -572,7 +564,6 @@ protocol.registerSchemesAsPrivileged([ secure: true, bypassCSP: true, supportFetchAPI: true, - stream: true, }, }, { @@ -736,13 +727,6 @@ if (!gotTheLock) { .fromPartition("persist:superset") .protocol.handle("superset-temp-audio", tempAudioHandler); - // Serve workspace audio/video files for the file viewer - const workspaceMediaHandler = createWorkspaceMediaProtocolHandler(); - protocol.handle("superset-workspace-media", workspaceMediaHandler); - session - .fromPartition("persist:superset") - .protocol.handle("superset-workspace-media", workspaceMediaHandler); - ensureProjectIconsDir(); setWorkspaceDockIcon(); initSentry(); @@ -773,6 +757,7 @@ if (!gotTheLock) { }); } + initNotifications(); await makeAppSetup(() => MainWindow()); setupAutoUpdater(); setupServiceStatusPolling(); diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts index db889900cd5..524302f38de 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts @@ -275,7 +275,8 @@ export function createClaudeWrapper(): void { } /** - * Creates the Codex wrapper that injects Superset's notify/session-log logic. + * Creates the Codex wrapper that enables native hooks and keeps the + * session-log watcher for prompt/permission events inside Superset terminals. */ export function createCodexWrapper(): void { const notifyPath = getNotifyScriptPath(); @@ -427,10 +428,10 @@ export function getCodexGlobalHooksJsonContent( * binary wrapper is not in PATH (e.g. user runs codex from outside * a Superset terminal). * - * The wrapper still injects Codex's native notify callback and keeps the - * session-log watcher as a best-effort bridge for older releases, but the - * native hooks.json registration is now the primary source for prompt/tool - * lifecycle events. + * The wrapper now only enables Codex hooks and keeps the session-log watcher + * as a best-effort bridge for prompt/permission events inside Superset + * terminals. Native hooks.json registration remains the primary lifecycle + * source to avoid duplicate completion notifications. */ export function createCodexHooksJson(): void { const notifyScriptPath = getNotifyScriptPath(); diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts index 9e96a3e4912..821e0d3d66d 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts @@ -197,9 +197,7 @@ describe("agent-wrappers copilot", () => { expect(wrapper).toContain('awk -F\'"approval_id":"\''); expect(wrapper).toContain('_superset_emit_event "Start"'); expect(wrapper).toContain('_superset_emit_event "PermissionRequest"'); - expect(wrapper).toContain( - `"$REAL_BIN" --enable codex_hooks -c 'notify=["bash","${path.join(TEST_HOOKS_DIR, "notify.sh")}"]' "$@"`, - ); + expect(wrapper).toContain(`"$REAL_BIN" --enable codex_hooks "$@"`); expect(wrapper).toContain("SUPERSET_CODEX_START_WATCHER_PID"); expect(wrapper).toContain('kill "$SUPERSET_CODEX_START_WATCHER_PID"'); @@ -242,8 +240,6 @@ exit 0 `${[ "--enable", "codex_hooks", - "-c", - `notify=["bash","${path.join(TEST_HOOKS_DIR, "notify.sh")}"]`, "exec", "Reply with exactly OK.", ].join("\n")}\n`, diff --git a/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.sh index 8aa12395105..86130c5e18d 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.sh @@ -1,6 +1,6 @@ -# Codex exposes completion notifications via notify. -# For per-prompt Start notifications and permission requests, watch the TUI -# session log for task_started/exec_command_begin and *_approval_request events. +# Native ~/.codex/hooks.json handles SessionStart/UserPromptSubmit/Stop. +# The wrapper keeps the session-log watcher only for per-prompt Start +# notifications and permission requests inside Superset terminals. if [ -n "$SUPERSET_TAB_ID" ] && [ -f "{{NOTIFY_PATH}}" ]; then export CODEX_TUI_RECORD_SESSION=1 if [ -z "$CODEX_TUI_SESSION_LOG_PATH" ]; then @@ -72,7 +72,7 @@ if [ -n "$SUPERSET_TAB_ID" ] && [ -f "{{NOTIFY_PATH}}" ]; then SUPERSET_CODEX_START_WATCHER_PID=$! fi -"$REAL_BIN" --enable codex_hooks -c 'notify=["bash","{{NOTIFY_PATH}}"]' "$@" +"$REAL_BIN" --enable codex_hooks "$@" SUPERSET_CODEX_STATUS=$? if [ -n "$SUPERSET_CODEX_START_WATCHER_PID" ]; then diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index a542061ad0f..d2bb178a745 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -1,3 +1,4 @@ +import type { Server } from "node:http"; import { join } from "node:path"; import * as Sentry from "@sentry/electron/main"; import { projects, workspaces, worktrees } from "@superset/local-db"; @@ -105,6 +106,20 @@ function buildAivisVars(event: AgentLifecycleEvent) { let currentWindow: BrowserWindow | null = null; let mainWindowCleanup: (() => void) | null = null; +let notificationsInitialized = false; +let notificationsServer: Server | null = null; +let notificationManager: NotificationManager | null = null; +let agentLifecycleListener: + | ((event: AgentLifecycleEvent) => void) + | null = null; +let terminalExitListener: + | ((event: { + paneId: string; + exitCode: number; + signal?: number; + reason?: "killed" | "exited" | "error"; + }) => void) + | null = null; /** Tear down main window resources (notification server, IPC, etc.) * without destroying the BrowserWindow itself. Called from before-quit @@ -112,6 +127,7 @@ let mainWindowCleanup: (() => void) | null = null; export function cleanupMainWindowResources(): void { mainWindowCleanup?.(); mainWindowCleanup = null; + cleanupNotifications(); } function addWindowLifecycleBreadcrumb( @@ -175,6 +191,110 @@ nativeTheme.on("updated", () => { } }); +export function initNotifications(): void { + if (notificationsInitialized) return; + + notificationManager = new NotificationManager({ + isSupported: () => Notification.isSupported(), + createNotification: (opts) => new Notification(opts), + playSound: playNotificationSound, + playAivis: (event) => { + const kind = + event.eventType === "PermissionRequest" ? "permission" : "complete"; + void playAivisNotification(kind, buildAivisVars(event)); + }, + onNotificationClick: (ids) => { + const window = getWindow(); + if (window && !window.isDestroyed()) { + window.show(); + window.focus(); + } else { + app.emit("activate"); + } + notificationsEmitter.emit(NOTIFICATION_EVENTS.FOCUS_TAB, ids); + }, + getVisibilityContext: () => { + const window = getWindow(); + const windowIsReady = window && !window.isDestroyed(); + return { + isFocused: windowIsReady ? window.isFocused() : false, + currentWorkspaceId: windowIsReady + ? extractWorkspaceIdFromUrl(window.webContents.getURL()) + : null, + tabsState: appState.data?.tabsState, + }; + }, + getWorkspaceName: getWorkspaceNameFromDb, + getNotificationTitle: (event) => + getNotificationTitle({ + tabId: event.tabId, + paneId: event.paneId, + tabs: appState.data?.tabsState?.tabs, + panes: appState.data?.tabsState?.panes, + }), + }); + notificationManager.start(); + + agentLifecycleListener = (event: AgentLifecycleEvent) => { + notificationManager?.handleAgentLifecycle(event); + }; + notificationsEmitter.on( + NOTIFICATION_EVENTS.AGENT_LIFECYCLE, + agentLifecycleListener, + ); + + terminalExitListener = (event) => { + notificationsEmitter.emit(NOTIFICATION_EVENTS.TERMINAL_EXIT, { + paneId: event.paneId, + exitCode: event.exitCode, + signal: event.signal, + reason: event.reason, + }); + }; + getWorkspaceRuntimeRegistry() + .getDefault() + .terminal.on("terminalExit", terminalExitListener); + + notificationsServer = notificationsApp.listen( + env.DESKTOP_NOTIFICATIONS_PORT, + "127.0.0.1", + () => { + console.log( + `[notifications] Listening on http://127.0.0.1:${env.DESKTOP_NOTIFICATIONS_PORT}`, + ); + }, + ); + + notificationsInitialized = true; +} + +function cleanupNotifications(): void { + if (!notificationsInitialized) return; + + if (agentLifecycleListener) { + notificationsEmitter.off( + NOTIFICATION_EVENTS.AGENT_LIFECYCLE, + agentLifecycleListener, + ); + agentLifecycleListener = null; + } + + if (terminalExitListener) { + getWorkspaceRuntimeRegistry() + .getDefault() + .terminal.off("terminalExit", terminalExitListener); + terminalExitListener = null; + } + + notificationManager?.dispose(); + notificationManager = null; + + notificationsServer?.close(); + notificationsServer = null; + + notificationsInitialized = false; +} + export async function MainWindow() { const shouldPersistWindowPosition = isWindowPositionPersistenceEnabled(); const savedWindowState = loadWindowState(); @@ -244,77 +364,6 @@ export async function MainWindow() { windowManager.setIpcHandler(ipcHandler); } - const server = notificationsApp.listen( - env.DESKTOP_NOTIFICATIONS_PORT, - "127.0.0.1", - () => { - console.log( - `[notifications] Listening on http://127.0.0.1:${env.DESKTOP_NOTIFICATIONS_PORT}`, - ); - }, - ); - - const notificationManager = new NotificationManager({ - isSupported: () => Notification.isSupported(), - createNotification: (opts) => new Notification(opts), - playSound: playNotificationSound, - playAivis: (event) => { - const kind = - event.eventType === "PermissionRequest" ? "permission" : "complete"; - void playAivisNotification(kind, buildAivisVars(event)); - }, - onNotificationClick: (ids) => { - window.show(); - window.focus(); - notificationsEmitter.emit(NOTIFICATION_EVENTS.FOCUS_TAB, ids); - }, - getVisibilityContext: () => ({ - isFocused: window.isFocused(), - currentWorkspaceId: extractWorkspaceIdFromUrl( - window.webContents.getURL(), - ), - tabsState: appState.data?.tabsState, - }), - getWorkspaceName: getWorkspaceNameFromDb, - getNotificationTitle: (event) => - getNotificationTitle({ - tabId: event.tabId, - paneId: event.paneId, - tabs: appState.data?.tabsState?.tabs, - panes: appState.data?.tabsState?.panes, - }), - }); - notificationManager.start(); - - notificationsEmitter.on( - NOTIFICATION_EVENTS.AGENT_LIFECYCLE, - (event: AgentLifecycleEvent) => { - notificationManager.handleAgentLifecycle(event); - }, - ); - - // Forward low-volume terminal lifecycle events to the renderer via the existing - // notifications subscription. This is used only for correctness (e.g. clearing - // stuck agent lifecycle statuses when terminal panes aren't mounted). - getWorkspaceRuntimeRegistry() - .getDefault() - .terminal.on( - "terminalExit", - (event: { - paneId: string; - exitCode: number; - signal?: number; - reason?: "killed" | "exited" | "error"; - }) => { - notificationsEmitter.emit(NOTIFICATION_EVENTS.TERMINAL_EXIT, { - paneId: event.paneId, - exitCode: event.exitCode, - signal: event.signal, - reason: event.reason, - }); - }, - ); - // macOS Sequoia+: occluded/minimized windows can lose compositor layers, // and NSVisualEffectView's vibrancy/native blur can detach while the // window is in the Dock — restoring without re-applying leaves the @@ -483,10 +532,6 @@ export async function MainWindow() { function doCleanup() { browserManager.unregisterAll(); - server.close(); - notificationManager.dispose(); - notificationsEmitter.removeAllListeners(); - getWorkspaceRuntimeRegistry().getDefault().terminal.detachAllListeners(); ipcHandler?.detachWindow(window); windowManager.unregister("main"); currentWindow = null; From c63be8d54364fc4c157dc15df309c43bee1bc085 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 18 Apr 2026 18:07:28 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(desktop):=20review=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C=E3=81=A8?= =?UTF-8?q?lint=E3=82=A8=E3=83=A9=E3=83=BC=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.ts の workspace-media-protocol 登録と stream 特権を誤って削除していたのを復旧 (CodeRabbit critical 指摘) - createCodexHooksJson のコメントを primary/fallback で矛盾しないよう修正 - codex wrapper テスト名から completion notifications 表記を除去 - biome フォーマット修正 (main.ts / agent-wrappers.test.ts) --- apps/desktop/src/main/index.ts | 20 +++++++++++++++++++ .../agent-wrappers-claude-codex-opencode.ts | 13 ++++++------ .../lib/agent-setup/agent-wrappers.test.ts | 11 ++++------ apps/desktop/src/main/windows/main.ts | 5 ++--- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 73068b5c95f..4a8a3586b7c 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -51,6 +51,7 @@ import { } from "./lib/terminal"; import { disposeTray, initTray } from "./lib/tray"; import { windowManager } from "./lib/window-manager"; +import { createWorkspaceMediaProtocolHandler } from "./lib/workspace-media-protocol"; // Lazy import to avoid module resolution issues during Vite build const loadVscodeShim = () => @@ -555,6 +556,17 @@ protocol.registerSchemesAsPrivileged([ secure: true, bypassCSP: true, supportFetchAPI: true, + stream: true, + }, + }, + { + scheme: "superset-workspace-media", + privileges: { + standard: true, + secure: true, + bypassCSP: true, + supportFetchAPI: true, + stream: true, }, }, { @@ -564,6 +576,7 @@ protocol.registerSchemesAsPrivileged([ secure: true, bypassCSP: true, supportFetchAPI: true, + stream: true, }, }, { @@ -727,6 +740,13 @@ if (!gotTheLock) { .fromPartition("persist:superset") .protocol.handle("superset-temp-audio", tempAudioHandler); + // Serve workspace audio/video files for the file viewer + const workspaceMediaHandler = createWorkspaceMediaProtocolHandler(); + protocol.handle("superset-workspace-media", workspaceMediaHandler); + session + .fromPartition("persist:superset") + .protocol.handle("superset-workspace-media", workspaceMediaHandler); + ensureProjectIconsDir(); setWorkspaceDockIcon(); initSentry(); diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts index 524302f38de..06ccefe25cb 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts @@ -424,14 +424,15 @@ export function getCodexGlobalHooksJsonContent( /** * Writes Superset hook definitions directly into ~/.codex/hooks.json. - * This provides a fallback notification path that works even when the - * binary wrapper is not in PATH (e.g. user runs codex from outside + * This is the primary lifecycle notification path for Codex and also works + * when the binary wrapper is not in PATH (e.g. user runs codex from outside * a Superset terminal). * - * The wrapper now only enables Codex hooks and keeps the session-log watcher - * as a best-effort bridge for prompt/permission events inside Superset - * terminals. Native hooks.json registration remains the primary lifecycle - * source to avoid duplicate completion notifications. + * The wrapper only enables Codex hooks and keeps the session-log watcher as a + * best-effort bridge for prompt/permission events inside Superset terminals. + * Completion notifications are handled exclusively via hooks.json to avoid + * the duplicate `/hook/complete` POSTs that occurred when the wrapper also + * injected `--notify=[...]`. */ export function createCodexHooksJson(): void { const notifyScriptPath = getNotifyScriptPath(); diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts index 821e0d3d66d..338c6e196f8 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts @@ -172,7 +172,7 @@ describe("agent-wrappers copilot", () => { expect(updated).not.toContain("/tmp/old-hook.sh"); }); - it("injects codex start + permission watchers and completion notifications in wrapper", () => { + it("injects codex start + permission watchers and enables native hooks", () => { createCodexWrapper(); const wrapperPath = path.join(TEST_BIN_DIR, "codex"); @@ -237,12 +237,9 @@ exit 0 }); expect(readFileSync(argsFile, "utf-8")).toBe( - `${[ - "--enable", - "codex_hooks", - "exec", - "Reply with exactly OK.", - ].join("\n")}\n`, + `${["--enable", "codex_hooks", "exec", "Reply with exactly OK."].join( + "\n", + )}\n`, ); }); diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index d2bb178a745..ad5d2cda604 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -109,9 +109,8 @@ let mainWindowCleanup: (() => void) | null = null; let notificationsInitialized = false; let notificationsServer: Server | null = null; let notificationManager: NotificationManager | null = null; -let agentLifecycleListener: - | ((event: AgentLifecycleEvent) => void) - | null = null; +let agentLifecycleListener: ((event: AgentLifecycleEvent) => void) | null = + null; let terminalExitListener: | ((event: { paneId: string; From 6e6a0ff684c221529f0e2385da42fe737f6bb494 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Sat, 18 Apr 2026 18:08:07 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs(desktop):=20EXTERNAL=5FFILES=20?= =?UTF-8?q?=E3=81=AE=20Codex=20wrapper=20=E8=AA=AC=E6=98=8E=E3=82=92=20not?= =?UTF-8?q?ify=20=E9=99=A4=E5=8E=BB=E5=BE=8C=E3=81=AE=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E3=81=A6=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/docs/EXTERNAL_FILES.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/desktop/docs/EXTERNAL_FILES.md b/apps/desktop/docs/EXTERNAL_FILES.md index 5cc01ad97cb..471bb2970e2 100644 --- a/apps/desktop/docs/EXTERNAL_FILES.md +++ b/apps/desktop/docs/EXTERNAL_FILES.md @@ -45,13 +45,15 @@ its hook entries into these files while preserving user-defined entries: | `~/.codex/hooks.json` | Codex hook registration merge (`SessionStart`, `UserPromptSubmit`, `Stop`) | | `~/.factory/settings.json` | Factory Droid hook registration (`UserPromptSubmit`, `Notification`, `PostToolUse`, `Stop`) | -For Codex specifically, Superset now relies on native `~/.codex/hooks.json` -registration for durable prompt/tool lifecycle events, while the wrapper in -`~/.superset[-{workspace}]/bin/codex` still injects `notify` and keeps the -session-log watcher as a best-effort compatibility bridge for older Codex -releases. On startup, Superset rewrites only its own managed entries in -`~/.codex/hooks.json` to point at the current environment's `notify.sh`, while -preserving any user-defined Codex hooks. +For Codex specifically, Superset relies on native `~/.codex/hooks.json` +registration as the sole source of completion notifications. The wrapper in +`~/.superset[-{workspace}]/bin/codex` only enables `codex_hooks` (by passing +`--enable codex_hooks` to the real binary) and keeps the session-log watcher +as a best-effort bridge for per-prompt Start notifications and permission +requests inside Superset terminals. It no longer injects `--notify=[...]` to +avoid duplicate `/hook/complete` POSTs. On startup, Superset rewrites only its +own managed entries in `~/.codex/hooks.json` to point at the current +environment's `notify.sh`, while preserving any user-defined Codex hooks. ### `zsh/` and `bash/` - Shell Integration