From af5ee32ff9cba8859ffa154ec58a7505fd961546 Mon Sep 17 00:00:00 2001 From: obviousbread Date: Thu, 11 Jun 2026 18:41:19 +0300 Subject: [PATCH 1/2] feat: add Oh My Pi (omp) support omp (the @oh-my-pi/pi-coding-agent harness) loads extension modules from ~/.omp/agent/extensions instead of reading hook commands from a config file, so the existing config-edit approach used for Claude/Codex/Gemini does not apply. `cn on omp` now writes a small managed extension (~/.omp/agent/extensions/code-notify.js) that forwards omp's agent_end event to notifier.sh via `stop omp `; `cn off omp` removes it. Because the contract back into notifier.sh is identical, all existing delivery machinery (sound, voice, Slack/Discord channels, click-through, the global mute kill switch, and rate limiting) works unchanged. - detect.sh: detect_omp + registry (get_installed_tools, is_tool_installed) - config.sh: omp paths, generate_omp_extension, is/enable/disable_omp_hooks, enable_tool/disable_tool/is_tool_enabled dispatch - notifier.sh: omp display name - global.sh: enable/disable/status wiring + supported-tools text - help.sh: omp listed in tool names and command help - tests/test-omp-extension.sh wired into run_tests.sh - README: Features, usage table, How It Works Scope: macOS/Linux, completion event (like Codex). Windows omp and a needs-input event are left as follow-ups. --- README.md | 6 +- lib/code-notify/commands/global.sh | 18 ++++- lib/code-notify/core/config.sh | 82 +++++++++++++++++++++++ lib/code-notify/core/notifier.sh | 1 + lib/code-notify/utils/detect.sh | 19 ++++++ lib/code-notify/utils/help.sh | 6 +- scripts/run_tests.sh | 8 +++ tests/test-all-alias.sh | 6 +- tests/test-omp-extension.sh | 102 +++++++++++++++++++++++++++++ 9 files changed, 240 insertions(+), 8 deletions(-) create mode 100755 tests/test-omp-extension.sh diff --git a/README.md b/README.md index 92b96f3..a921c1c 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ cn usage status ## Features -- **Multi-tool support** - Claude Code, OpenAI Codex, Google Gemini CLI +- **Multi-tool support** - Claude Code, OpenAI Codex, Google Gemini CLI, Oh My Pi (omp) - **Works everywhere** - Terminal, VSCode, Cursor, or any editor - **Cross-platform** - macOS, Linux, Windows - **Native notifications** - Uses system notification APIs @@ -145,6 +145,7 @@ npm packages are published with GitHub Actions Trusted Publisher and npm provena | `cn on claude` | Enable for Claude Code only | | `cn on codex` | Enable for Codex only | | `cn on gemini` | Enable for Gemini CLI only | +| `cn on omp` | Enable for Oh My Pi (omp) only | | `cn off` | Disable notifications | | `cn off all` | Explicit alias for disabling all tools | | `cn test` | Send test notification | @@ -173,10 +174,13 @@ Code-Notify uses the hook systems built into AI coding tools: - **Claude Code**: `~/.claude/settings.json` - **Codex**: `~/.codex/config.toml` - **Gemini CLI**: `~/.gemini/settings.json` +- **Oh My Pi (omp)**: `~/.omp/agent/extensions/code-notify.js` For Codex, Code-Notify configures `notify = ["/absolute/path/to/notifier.sh", "codex"]` and reads the JSON payload Codex appends on completion. Codex currently exposes completion events through `notify`; approval and `request_permissions` prompts do not currently arrive through this hook. +For omp, Code-Notify writes a small managed extension module to `~/.omp/agent/extensions/code-notify.js`, because omp loads extension modules instead of reading hook commands from a config file. The extension forwards omp's `agent_end` event to the same `notifier.sh`, so sound, voice, Slack/Discord channels, click-through, the global mute switch, and rate limiting all work unchanged. Like Codex, omp currently exposes completion events; approval and idle prompts are not yet wired. + When enabled, it adds hooks that call the notification script when tasks complete: ```json diff --git a/lib/code-notify/commands/global.sh b/lib/code-notify/commands/global.sh index ecb69f9..d3ae25e 100755 --- a/lib/code-notify/commands/global.sh +++ b/lib/code-notify/commands/global.sh @@ -373,7 +373,7 @@ enable_notifications_global() { if [[ -z "$installed_tools" ]]; then warning "No supported AI tools detected" - info "Supported tools: Claude Code, Codex, Gemini CLI" + info "Supported tools: Claude Code, Codex, Gemini CLI, Oh My Pi (omp)" return 1 fi @@ -440,6 +440,7 @@ enable_single_tool() { "claude") config_file="$GLOBAL_SETTINGS_FILE" ;; "codex") config_file="$CODEX_CONFIG_FILE" ;; "gemini") config_file="$GEMINI_SETTINGS_FILE" ;; + "omp") config_file="$OMP_EXTENSION_FILE" ;; esac success "$tool: ENABLED" @@ -470,7 +471,7 @@ disable_notifications_global() { # No tool specified - disable all enabled tools local disabled_count=0 - for t in claude codex gemini; do + for t in claude codex gemini omp; do if is_tool_enabled "$t"; then if disable_single_tool "$t" "quiet"; then ((disabled_count++)) @@ -580,6 +581,19 @@ show_status() { echo " ${DIM}- Gemini CLI: not installed${RESET}" fi + # Oh My Pi (omp) + if is_tool_installed "omp"; then + if is_tool_enabled "omp"; then + echo " ${CHECK_MARK} omp: ${GREEN}ENABLED${RESET}" + echo " Config: $OMP_EXTENSION_FILE" + echo " Events: completion via agent_end extension" + else + echo " ${MUTE} omp: ${DIM}DISABLED${RESET}" + fi + else + echo " ${DIM}- omp: not installed${RESET}" + fi + # Voice status echo "" if is_voice_enabled "global"; then diff --git a/lib/code-notify/core/config.sh b/lib/code-notify/core/config.sh index 971478b..b1ff3bf 100755 --- a/lib/code-notify/core/config.sh +++ b/lib/code-notify/core/config.sh @@ -54,6 +54,12 @@ CODEX_CONFIG_FILE="$CODEX_HOME/config.toml" GEMINI_HOME="${GEMINI_HOME:-$HOME/.gemini}" GEMINI_SETTINGS_FILE="$GEMINI_HOME/settings.json" +# Oh My Pi (omp) paths +OMP_HOME="${OMP_HOME:-$HOME/.omp}" +OMP_EXTENSIONS_DIR="${OMP_EXTENSIONS_DIR:-$OMP_HOME/agent/extensions}" +OMP_EXTENSION_FILE="$OMP_EXTENSIONS_DIR/code-notify.js" +OMP_EXTENSION_MARKER="code-notify: managed omp extension" + # Ensure config directory exists ensure_config_dir() { mkdir -p "$CONFIG_DIR" "$BACKUP_DIR" @@ -1551,6 +1557,73 @@ PYTHON fi } +# ============================================ +# Oh My Pi (omp) integration +# ============================================ +# omp loads JS/TS extension modules from ~/.omp/agent/extensions instead of +# reading hook commands from a config file. So enabling = writing a managed +# extension that forwards omp's agent_end event to notifier.sh; disabling = +# removing that file. All downstream delivery (sound, voice, channels, +# click-through, kill switch, rate limiting) is reused through notifier.sh. + +# Generate the omp extension module that forwards completion events to the notifier +generate_omp_extension() { + local notify_script="$1" + # Escape backslashes and double quotes for safe embedding in the JS string literal + notify_script="${notify_script//\\/\\\\}" + notify_script="${notify_script//\"/\\\"}" + cat << EOF +// ${OMP_EXTENSION_MARKER} +// Created by \`cn on omp\`; removed by \`cn off omp\`. Do not edit -- regenerated on enable. +// omp (Oh My Pi) loads extension modules instead of config-file hooks, so this +// file forwards the "agent finished" event to code-notify's notifier. +const NOTIFIER = "${notify_script}"; + +export default function codeNotify(pi) { + pi.on("agent_end", async (_event, ctx) => { + // Only ping the top-level interactive session; skip subagents/headless runs + // unless OMP_NOTIFY_ALL=1 is set. + if (!ctx.hasUI && process.env.OMP_NOTIFY_ALL !== "1") return; + const project = (ctx.cwd || "").split("/").filter(Boolean).pop() || ""; + try { + await pi.exec(NOTIFIER, ["stop", "omp", project], { timeout: 5000 }); + } catch { + // Never let notification failures disrupt the session. + } + }); +} +EOF +} + +# Check if omp notifications are enabled +is_omp_enabled() { + [[ -f "$OMP_EXTENSION_FILE" ]] && grep -q "$OMP_EXTENSION_MARKER" "$OMP_EXTENSION_FILE" 2>/dev/null +} + +# Enable omp notifications by writing a managed extension module +enable_omp_hooks() { + local notify_script + notify_script="$(get_notify_script)" + + mkdir -p "$OMP_EXTENSIONS_DIR" + + # We own this file entirely, so a plain atomic write is enough (no jq/python merge needed) + atomic_write "$OMP_EXTENSION_FILE" "$(generate_omp_extension "$notify_script")" +} + +# Disable omp notifications by removing our managed extension module +disable_omp_hooks() { + if [[ ! -f "$OMP_EXTENSION_FILE" ]]; then + return 0 + fi + + # Only remove the file if it is the one code-notify generated + if grep -q "$OMP_EXTENSION_MARKER" "$OMP_EXTENSION_FILE" 2>/dev/null; then + rm -f "$OMP_EXTENSION_FILE" + fi + return 0 +} + # ============================================ # Multi-tool helpers # ============================================ @@ -1569,6 +1642,9 @@ enable_tool() { "gemini") enable_gemini_hooks ;; + "omp") + enable_omp_hooks + ;; *) return 1 ;; @@ -1589,6 +1665,9 @@ disable_tool() { "gemini") disable_gemini_hooks ;; + "omp") + disable_omp_hooks + ;; *) return 1 ;; @@ -1609,6 +1688,9 @@ is_tool_enabled() { "gemini") is_gemini_enabled ;; + "omp") + is_omp_enabled + ;; *) return 1 ;; diff --git a/lib/code-notify/core/notifier.sh b/lib/code-notify/core/notifier.sh index 80ba88a..7eae839 100755 --- a/lib/code-notify/core/notifier.sh +++ b/lib/code-notify/core/notifier.sh @@ -128,6 +128,7 @@ get_tool_display_name() { "claude") echo "Claude" ;; "codex") echo "Codex" ;; "gemini") echo "Gemini" ;; + "omp") echo "omp" ;; *) echo "AI" ;; esac } diff --git a/lib/code-notify/utils/detect.sh b/lib/code-notify/utils/detect.sh index e09d3a5..06a684f 100755 --- a/lib/code-notify/utils/detect.sh +++ b/lib/code-notify/utils/detect.sh @@ -121,6 +121,18 @@ detect_gemini_cli() { return 1 } +# Detect Oh My Pi (omp) installation +detect_omp() { + # Check if the omp (or legacy pi) command exists, or the config dir is present + if command -v omp &> /dev/null || command -v pi &> /dev/null || [[ -d "$HOME/.omp" ]]; then + # Return config location + local config_dir="$HOME/.omp" + echo "$config_dir" + return 0 + fi + return 1 +} + # Get list of all installed AI coding tools get_installed_tools() { local tools=() @@ -137,6 +149,10 @@ get_installed_tools() { tools+=("gemini") fi + if detect_omp &> /dev/null; then + tools+=("omp") + fi + # Return space-separated list echo "${tools[*]}" } @@ -155,6 +171,9 @@ is_tool_installed() { "gemini") detect_gemini_cli &> /dev/null ;; + "omp") + detect_omp &> /dev/null + ;; *) return 1 ;; diff --git a/lib/code-notify/utils/help.sh b/lib/code-notify/utils/help.sh index 836625b..c78dc77 100644 --- a/lib/code-notify/utils/help.sh +++ b/lib/code-notify/utils/help.sh @@ -14,7 +14,7 @@ show_help() { ${BOLD}Code-Notify${RESET} - Desktop notifications for AI coding tools ${BOLD}SUPPORTED TOOLS:${RESET} - Claude Code, OpenAI Codex, Google Gemini CLI + Claude Code, OpenAI Codex, Google Gemini CLI, Oh My Pi (omp) ${BOLD}USAGE:${RESET} $cmd_name [tool] @@ -22,7 +22,7 @@ ${BOLD}USAGE:${RESET} ${BOLD}COMMANDS:${RESET} ${GREEN}on${RESET} Enable notifications (all detected tools) ${GREEN}on${RESET} all Enable notifications (explicit alias for all detected tools) - ${GREEN}on${RESET} Enable for specific tool (claude/codex/gemini) + ${GREEN}on${RESET} Enable for specific tool (claude/codex/gemini/omp) ${GREEN}off${RESET} Disable notifications (all tools) ${GREEN}off${RESET} all Disable notifications (explicit alias for all tools) ${GREEN}off${RESET} Disable for specific tool @@ -51,6 +51,7 @@ ${BOLD}TOOL NAMES:${RESET} ${CYAN}claude${RESET} Claude Code ${CYAN}codex${RESET} OpenAI Codex CLI ${CYAN}gemini${RESET} Google Gemini CLI + ${CYAN}omp${RESET} Oh My Pi (omp) ${BOLD}PROJECT COMMANDS:${RESET} ${GREEN}project on${RESET} Enable for current project @@ -67,6 +68,7 @@ ${BOLD}ALERT TYPES:${RESET} Claude events: ${CYAN}SubagentStart${RESET}, ${CYAN}SubagentStop${RESET}, ${CYAN}TeammateIdle${RESET}, ${CYAN}TaskCreated${RESET}, ${CYAN}TaskCompleted${RESET} Note: alert-type matching applies to Claude Code and Gemini CLI hooks. Codex currently exposes completion events through its notify payload. + omp exposes completion events through a generated extension module. ${BOLD}VOICE COMMANDS:${RESET} ${GREEN}voice on${RESET} Enable voice for all tools diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 843c247..bf472b9 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -251,6 +251,14 @@ else test_fail "ask_user alert preservation failed" fi +# Test 24: omp (Oh My Pi) extension install/detect/notify +test_start "omp extension support" +if bash tests/test-omp-extension.sh >/dev/null 2>&1; then + test_pass +else + test_fail "omp extension support failed" +fi + # Summary echo "" echo "Test Summary:" diff --git a/tests/test-all-alias.sh b/tests/test-all-alias.sh index bcce52e..1e9704c 100644 --- a/tests/test-all-alias.sh +++ b/tests/test-all-alias.sh @@ -22,7 +22,7 @@ run_enable_all_alias_test() { source "$SCRIPT_DIR/../lib/code-notify/core/config.sh" source "$SCRIPT_DIR/../lib/code-notify/commands/global.sh" - get_installed_tools() { echo "claude codex gemini"; } + get_installed_tools() { echo "claude codex gemini omp"; } is_tool_installed() { return 0; } is_tool_enabled() { return 1; } enable_tool() { @@ -35,7 +35,7 @@ run_enable_all_alias_test() { local enabled_tools enabled_tools="$(sort "$HOME/enabled-tools" | tr '\n' ' ')" - [[ "$enabled_tools" == "claude codex gemini " ]] || fail "cn on all did not enable every detected tool" + [[ "$enabled_tools" == "claude codex gemini omp " ]] || fail "cn on all did not enable every detected tool" ) rm -rf "$test_dir" @@ -66,7 +66,7 @@ run_disable_all_alias_test() { local disabled_tools disabled_tools="$(sort "$HOME/disabled-tools" | tr '\n' ' ')" - [[ "$disabled_tools" == "claude codex gemini " ]] || fail "cn off all did not disable every enabled tool" + [[ "$disabled_tools" == "claude codex gemini omp " ]] || fail "cn off all did not disable every enabled tool" ) rm -rf "$test_dir" diff --git a/tests/test-omp-extension.sh b/tests/test-omp-extension.sh new file mode 100755 index 0000000..d0dfca3 --- /dev/null +++ b/tests/test-omp-extension.sh @@ -0,0 +1,102 @@ +#!/bin/bash + +# Verifies Oh My Pi (omp) support: +# - detection from PATH / config dir +# - `enable` writes a valid managed agent_end extension module +# - the generated extension's runtime contract (notifier.sh stop omp ) +# - `disable` removes only the managed file, preserving user-owned ones + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_DIR="$SCRIPT_DIR/../lib/code-notify" +NOTIFIER="$LIB_DIR/core/notifier.sh" + +pass() { echo "PASS: $1"; } +fail() { echo "FAIL: $1"; exit 1; } + +test_dir="$(mktemp -d)" +trap 'rm -rf "$test_dir"' EXIT + +export HOME="$test_dir/home" +fake_bin="$test_dir/bin" +log_dir="$test_dir/log" +mkdir -p "$HOME/.claude/notifications" "$HOME/.claude/logs" "$fake_bin" "$log_dir" + +# Fake desktop notifier so the runtime contract can be asserted headlessly. +case "$(uname -s)" in + Darwin) + notification_log="$log_dir/terminal-notifier.log" + cat > "$fake_bin/terminal-notifier" <> "$notification_log" +EOF + ;; + Linux) + notification_log="$log_dir/notify-send.log" + cat > "$fake_bin/notify-send" <> "$notification_log" +EOF + ;; + *) + echo "SKIP: unsupported OS for omp extension test" + exit 0 + ;; +esac + +# Fake omp binary so detection reports omp as installed. +cat > "$fake_bin/omp" <<'EOF' +#!/bin/bash +exit 0 +EOF +chmod +x "$fake_bin"/* + +# Source the library under the temp HOME so omp paths resolve into the sandbox. +source "$LIB_DIR/utils/detect.sh" +source "$LIB_DIR/core/config.sh" + +ext_file="$HOME/.omp/agent/extensions/code-notify.js" + +# 1. Detection picks up omp when the command is present. +PATH="$fake_bin:$PATH" is_tool_installed "omp" || fail "omp not detected when the 'omp' binary is present" +case " $(PATH="$fake_bin:$PATH" get_installed_tools) " in + *" omp "*) ;; + *) fail "get_installed_tools did not include omp" ;; +esac +pass "omp detected from PATH" + +# 2. Enable writes a valid managed extension module. +enable_omp_hooks || fail "enable_omp_hooks failed" +[[ -f "$ext_file" ]] || fail "extension file not created at $ext_file" +grep -q "$OMP_EXTENSION_MARKER" "$ext_file" || fail "managed marker missing from extension" +grep -q 'pi.on("agent_end"' "$ext_file" || fail "extension does not hook agent_end" +grep -q '"stop", "omp"' "$ext_file" || fail "extension does not call notifier with 'stop omp'" +grep -q "$NOTIFIER" "$ext_file" || fail "extension does not embed the resolved notifier path" +is_omp_enabled || fail "is_omp_enabled returned false after enable" +if command -v node >/dev/null 2>&1; then + node --check "$ext_file" || fail "generated extension is not valid JavaScript" +fi +pass "enable writes a valid managed agent_end extension" + +# 3. Runtime contract: notifier.sh stop omp renders a completion notification. +PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + CODE_NOTIFY_STOP_RATE_LIMIT_SECONDS=0 \ + bash "$NOTIFIER" stop omp demo ' as a completion notification" + +# 4. Disable removes the managed file. +disable_omp_hooks || fail "disable_omp_hooks failed" +[[ ! -f "$ext_file" ]] || fail "managed extension not removed on disable" +is_omp_enabled && fail "is_omp_enabled returned true after disable" +pass "disable removes the managed extension" + +# 5. Disable preserves a user-owned file at the same path. +mkdir -p "$(dirname "$ext_file")" +printf '%s\n' "// my own omp extension" > "$ext_file" +disable_omp_hooks || fail "disable_omp_hooks failed on a user-owned file" +[[ -f "$ext_file" ]] || fail "disable clobbered a non-managed extension file" +pass "disable preserves a non-managed extension file" + +echo "All omp extension tests passed" From 078e605e04d2cb24fe656523ed5d561bb5cb745d Mon Sep 17 00:00:00 2001 From: obviousbread Date: Fri, 12 Jun 2026 01:20:45 +0300 Subject: [PATCH 2/2] fix: harden omp integration (mute, detection, failure outcomes) Review fixes on top of af5ee32 (initial omp support): - Global mute / `cn off` now suppress omp notifications. The extension passed the project name as the notifier's 3rd arg, which marks a notification "project-scoped" and bypasses the kill switch. It now passes the project via opts.cwd, so basename($PWD) still scopes the text while the notification stays globally mutable. - detect_omp is binary-only (command -v omp), matching detect_codex / detect_gemini_cli. The old directory probe keyed on ~/.omp/agent, which code-notify itself partially creates on enable and which survives an omp uninstall, so it falsely reported omp as installed. disable_omp_hooks now removes only its own managed file and never touches ~/.omp/agent (omp's data dir). - agent_end maps the final assistant stopReason to the notifier hook type, so errored/aborted runs notify as errors instead of a false "Task Complete". Scans back to the last assistant message instead of assuming the array's last element. - enable refuses to overwrite a non-managed extension at the same path. - omp wired into per-tool voice status/teardown; Windows warns that omp is not yet supported. - tests: functional JS harness for the extension's runtime contract, plus mute, refuse-overwrite, and binary-only detection coverage. --- lib/code-notify/commands/global.sh | 3 +- lib/code-notify/core/config.sh | 36 +++++-- lib/code-notify/utils/detect.sh | 15 +-- lib/code-notify/utils/voice.sh | 1 + scripts/install-windows.ps1 | 13 ++- tests/test-omp-extension.sh | 161 +++++++++++++++++++++++++++-- 6 files changed, 204 insertions(+), 25 deletions(-) diff --git a/lib/code-notify/commands/global.sh b/lib/code-notify/commands/global.sh index d3ae25e..0779e6c 100755 --- a/lib/code-notify/commands/global.sh +++ b/lib/code-notify/commands/global.sh @@ -951,12 +951,13 @@ show_voice_status() { fi # Per-tool voice - for tool in claude codex gemini; do + for tool in claude codex gemini omp; do local tool_display case "$tool" in "claude") tool_display="Claude" ;; "codex") tool_display="Codex" ;; "gemini") tool_display="Gemini" ;; + "omp") tool_display="omp" ;; esac if is_voice_enabled "tool" "$tool"; then diff --git a/lib/code-notify/core/config.sh b/lib/code-notify/core/config.sh index b1ff3bf..f953c09 100755 --- a/lib/code-notify/core/config.sh +++ b/lib/code-notify/core/config.sh @@ -1580,15 +1580,29 @@ generate_omp_extension() { const NOTIFIER = "${notify_script}"; export default function codeNotify(pi) { - pi.on("agent_end", async (_event, ctx) => { + pi.on("agent_end", async (event, ctx) => { // Only ping the top-level interactive session; skip subagents/headless runs // unless OMP_NOTIFY_ALL=1 is set. if (!ctx.hasUI && process.env.OMP_NOTIFY_ALL !== "1") return; - const project = (ctx.cwd || "").split("/").filter(Boolean).pop() || ""; + // Map the final stop reason to the notifier hook type so a failed run produces an + // error notification instead of a false success. Scan back to the last assistant + // turn: the array can end in a tool-result or custom (bash/compaction) message + // that would otherwise mask the outcome. + const messages = Array.isArray(event?.messages) ? event.messages : []; + let reason; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i] && messages[i].role === "assistant") { reason = messages[i].stopReason; break; } + } + const hook = (reason === "error" || reason === "aborted" || reason === "length") + ? "error" : "stop"; try { - await pi.exec(NOTIFIER, ["stop", "omp", project], { timeout: 5000 }); - } catch { + // Pass the project name via cwd so the notifier derives it from basename(\$PWD), + // keeping the notification scoped globally (not project-scoped) so that + // \`cn off\` / the global kill switch correctly suppresses it. + await pi.exec(NOTIFIER, [hook, "omp"], { timeout: 5000, cwd: ctx.cwd || undefined }); + } catch (err) { // Never let notification failures disrupt the session. + pi.logger?.debug?.(\`code-notify: notifier exec failed: \${err}\`); } }); } @@ -1605,9 +1619,16 @@ enable_omp_hooks() { local notify_script notify_script="$(get_notify_script)" - mkdir -p "$OMP_EXTENSIONS_DIR" + # Refuse to overwrite a file we didn't create (consistent with disable_omp_hooks which + # preserves non-managed files). Check before creating any dirs so a refused enable + # leaves the filesystem untouched. + if [[ -f "$OMP_EXTENSION_FILE" ]] && ! grep -q "$OMP_EXTENSION_MARKER" "$OMP_EXTENSION_FILE" 2>/dev/null; then + error "Refusing to overwrite existing non-code-notify extension: $OMP_EXTENSION_FILE" + info "Remove or rename that file manually, then run \`cn on omp\` again." + return 1 + fi - # We own this file entirely, so a plain atomic write is enough (no jq/python merge needed) + mkdir -p "$OMP_EXTENSIONS_DIR" atomic_write "$OMP_EXTENSION_FILE" "$(generate_omp_extension "$notify_script")" } @@ -1617,7 +1638,8 @@ disable_omp_hooks() { return 0 fi - # Only remove the file if it is the one code-notify generated + # Only remove our own managed file. Never touch ~/.omp/agent or its subdirs -- + # that is omp's data directory (agent.db, sessions, config.yml, ...), not ours. if grep -q "$OMP_EXTENSION_MARKER" "$OMP_EXTENSION_FILE" 2>/dev/null; then rm -f "$OMP_EXTENSION_FILE" fi diff --git a/lib/code-notify/utils/detect.sh b/lib/code-notify/utils/detect.sh index 06a684f..1a8f17d 100755 --- a/lib/code-notify/utils/detect.sh +++ b/lib/code-notify/utils/detect.sh @@ -121,13 +121,16 @@ detect_gemini_cli() { return 1 } -# Detect Oh My Pi (omp) installation +# Detect Oh My Pi (omp) installation. +# Binary-only, like detect_codex / detect_gemini_cli: a directory is not a reliable +# "installed" signal here. ~/.omp/agent is omp's data dir (agent.db, sessions, ...) -- +# it persists after the binary is uninstalled, and code-notify itself creates the +# extensions/ subdir on enable, so keying on it gives false positives. The legacy `pi` +# alias is intentionally NOT accepted: `pi` is a common generic binary name and would +# misfire. OMP_HOME is still honoured for the echoed config location. detect_omp() { - # Check if the omp (or legacy pi) command exists, or the config dir is present - if command -v omp &> /dev/null || command -v pi &> /dev/null || [[ -d "$HOME/.omp" ]]; then - # Return config location - local config_dir="$HOME/.omp" - echo "$config_dir" + if command -v omp &> /dev/null; then + echo "${OMP_HOME:-$HOME/.omp}" return 0 fi return 1 diff --git a/lib/code-notify/utils/voice.sh b/lib/code-notify/utils/voice.sh index b598d92..3d9b3e9 100644 --- a/lib/code-notify/utils/voice.sh +++ b/lib/code-notify/utils/voice.sh @@ -72,6 +72,7 @@ disable_voice() { rm -f "$VOICE_DIR/voice-claude" rm -f "$VOICE_DIR/voice-codex" rm -f "$VOICE_DIR/voice-gemini" + rm -f "$VOICE_DIR/voice-omp" ;; "global"|*) rm -f "$GLOBAL_VOICE_FILE" diff --git a/scripts/install-windows.ps1 b/scripts/install-windows.ps1 index e12b1d3..51d32f7 100644 --- a/scripts/install-windows.ps1 +++ b/scripts/install-windows.ps1 @@ -1908,24 +1908,31 @@ function Invoke-CodeNotify { ) $toolCommands = @("claude", "codex", "gemini") + $unsupportedTools = @("omp") switch ($Command.ToLower()) { "on" { - if ($SubCommand -and ($toolCommands -contains $SubCommand.ToLower())) { + if ($SubCommand -and ($unsupportedTools -contains $SubCommand.ToLower())) { + Write-Warning "omp is not yet supported on Windows. Windows support is planned." + } elseif ($SubCommand -and ($toolCommands -contains $SubCommand.ToLower())) { Enable-Notifications -Tool $SubCommand } else { Enable-Notifications } } "off" { - if ($SubCommand -and ($toolCommands -contains $SubCommand.ToLower())) { + if ($SubCommand -and ($unsupportedTools -contains $SubCommand.ToLower())) { + Write-Warning "omp is not yet supported on Windows. Windows support is planned." + } elseif ($SubCommand -and ($toolCommands -contains $SubCommand.ToLower())) { Disable-Notifications -Tool $SubCommand } else { Disable-Notifications } } "status" { - if ($SubCommand -and ($toolCommands -contains $SubCommand.ToLower())) { + if ($SubCommand -and ($unsupportedTools -contains $SubCommand.ToLower())) { + Write-Warning "omp is not yet supported on Windows. Windows support is planned." + } elseif ($SubCommand -and ($toolCommands -contains $SubCommand.ToLower())) { Show-Status -Tool $SubCommand } else { Show-Status diff --git a/tests/test-omp-extension.sh b/tests/test-omp-extension.sh index d0dfca3..8df3724 100755 --- a/tests/test-omp-extension.sh +++ b/tests/test-omp-extension.sh @@ -5,6 +5,9 @@ # - `enable` writes a valid managed agent_end extension module # - the generated extension's runtime contract (notifier.sh stop omp ) # - `disable` removes only the managed file, preserving user-owned ones +# - global kill switch suppresses omp stop notifications +# - `enable` refuses to overwrite a non-managed extension file +# - `detect_omp` does not stay true after enable+disable with no binary set -e @@ -71,22 +74,125 @@ enable_omp_hooks || fail "enable_omp_hooks failed" [[ -f "$ext_file" ]] || fail "extension file not created at $ext_file" grep -q "$OMP_EXTENSION_MARKER" "$ext_file" || fail "managed marker missing from extension" grep -q 'pi.on("agent_end"' "$ext_file" || fail "extension does not hook agent_end" -grep -q '"stop", "omp"' "$ext_file" || fail "extension does not call notifier with 'stop omp'" +# Extension now passes only [hook, "omp"] -- no project arg3. +grep -q '"omp"]' "$ext_file" || fail "extension does not call notifier with 'omp' as tool arg" grep -q "$NOTIFIER" "$ext_file" || fail "extension does not embed the resolved notifier path" is_omp_enabled || fail "is_omp_enabled returned false after enable" + +# JS validity check: copy to .mjs so Node parses it as ESM regardless of version. +if command -v node >/dev/null 2>&1; then + cp "$ext_file" "$test_dir/code-notify-check.mjs" + node --check "$test_dir/code-notify-check.mjs" || fail "generated extension is not valid JavaScript" +fi + +# Functional import harness: load the extension under a fake pi API and verify +# that the agent_end handler calls exec with the right arguments. if command -v node >/dev/null 2>&1; then - node --check "$ext_file" || fail "generated extension is not valid JavaScript" + node --input-type=module < calls exec with ["stop","omp"] +capturedArgs = undefined; +await fakePi._handler({ messages: [] }, { hasUI: true, cwd: "/Users/me/myrepo" }); +if (!capturedArgs) throw new Error("exec not called for normal stop"); +if (capturedArgs.args[0] !== "stop") throw new Error("expected hook=stop, got " + capturedArgs.args[0]); +if (capturedArgs.args[1] !== "omp") throw new Error("expected tool=omp"); +if (capturedArgs.args.length !== 2) throw new Error("project must NOT be passed as arg3 (kill-switch fix)"); +if (capturedArgs.opts?.cwd !== "/Users/me/myrepo") throw new Error("cwd must be passed via opts.cwd"); + +// Case B: error outcome -> calls exec with ["error","omp"] +capturedArgs = undefined; +const errMsg = { role: "assistant", stopReason: "error" }; +await fakePi._handler({ messages: [errMsg] }, { hasUI: true, cwd: "/Users/me/myrepo" }); +if (!capturedArgs) throw new Error("exec not called for error outcome"); +if (capturedArgs.args[0] !== "error") throw new Error("expected hook=error for error stopReason, got " + capturedArgs.args[0]); + +// Case C: aborted -> calls exec with ["error","omp"] +capturedArgs = undefined; +const abortMsg = { role: "assistant", stopReason: "aborted" }; +await fakePi._handler({ messages: [abortMsg] }, { hasUI: true, cwd: "/Users/me/myrepo" }); +if (!capturedArgs) throw new Error("exec not called for aborted outcome"); +if (capturedArgs.args[0] !== "error") throw new Error("expected hook=error for aborted, got " + capturedArgs.args[0]); + +// Case D: headless session (hasUI=false, OMP_NOTIFY_ALL unset) -> NOT called +capturedArgs = undefined; +await fakePi._handler({ messages: [] }, { hasUI: false, cwd: "/Users/me/myrepo" }); +if (capturedArgs) throw new Error("exec must not be called for headless sessions"); + +// Case E: headless with OMP_NOTIFY_ALL=1 -> called +process.env.OMP_NOTIFY_ALL = "1"; +capturedArgs = undefined; +await fakePi._handler({ messages: [] }, { hasUI: false, cwd: "/Users/me/myrepo" }); +if (!capturedArgs) throw new Error("exec must be called when OMP_NOTIFY_ALL=1"); +delete process.env.OMP_NOTIFY_ALL; + +// Case F: exec throws -> does NOT propagate (catch absorbs) +capturedArgs = undefined; +const throwingPi = Object.assign({}, fakePi, { async exec() { throw new Error("spawn ENOENT"); } }); +throwingPi._handler = null; +factory(throwingPi); +await throwingPi._handler({ messages: [] }, { hasUI: true, cwd: "/Users/me/myrepo" }); +// If we reach here, the error was absorbed -- correct. + +console.log("HARNESS OK"); +HARNESS + [[ $? -eq 0 ]] || fail "functional JS harness failed" + pass "functional JS harness: all runtime contract cases pass" fi -pass "enable writes a valid managed agent_end extension" -# 3. Runtime contract: notifier.sh stop omp renders a completion notification. -PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" \ - CODE_NOTIFY_STOP_RATE_LIMIT_SECONDS=0 \ - bash "$NOTIFIER" stop omp demo ' as a completion notification" +grep -q "omp completed the task" "$notification_log" || fail "stop omp did not use the omp display name" +pass "notifier renders 'stop omp' as a completion notification" + +# 3b. Kill-switch suppresses omp stop notifications (P1 regression test). +> "$notification_log" +touch "$HOME/.claude/notifications/disabled" +( + cd "$test_dir" + mkdir -p demo2 && cd demo2 + PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + CODE_NOTIFY_STOP_RATE_LIMIT_SECONDS=0 \ + bash "$NOTIFIER" stop omp "$ext_file" +enable_omp_hooks 2>/dev/null && fail "enable_omp_hooks should have refused to overwrite non-managed file" +[[ "$(head -n 1 "$ext_file")" == "// user's own extension -- do not clobber" ]] || \ + fail "enable_omp_hooks clobbered a non-managed extension file" +# Clean up for detection test +rm -f "$ext_file" +pass "enable refuses to overwrite a non-managed extension file" + +# 7. Detection is binary-only (matches detect_codex / detect_gemini_cli). Leftover +# ~/.omp dirs -- from a prior `cn on omp`, or from omp's own data that survives a +# binary uninstall -- must NOT make detect_omp report omp as installed. +( + sticky_home="$(mktemp -d)/home" + export HOME="$sticky_home" + mkdir -p "$sticky_home" + export OMP_HOME="$sticky_home/.omp" + # config.sh was sourced at the top of this file; its OMP_* vars leak into this + # subshell and would shadow the fresh OMP_HOME (config.sh uses ${VAR:-default}). + # Clear them so every omp path recomputes under sticky_home. + unset OMP_EXTENSIONS_DIR OMP_EXTENSION_FILE + source "$LIB_DIR/utils/detect.sh" + source "$LIB_DIR/core/config.sh" + PATH="/usr/bin:/bin:/usr/sbin:/sbin" # deliberately no omp binary on PATH + # Simulate leftovers: omp's own data dir, plus a full enable+disable cycle. + mkdir -p "$OMP_HOME/agent/sessions" + : > "$OMP_HOME/agent/agent.db" + enable_omp_hooks &>/dev/null || true + disable_omp_hooks &>/dev/null || true + if detect_omp &>/dev/null; then + echo "FAIL: detect_omp reported omp installed with no binary (leftover ~/.omp dir fooled it)" + exit 1 + fi + rm -rf "$sticky_home" +) +pass "detect_omp is binary-only: leftover ~/.omp dirs do not trigger detection" + echo "All omp extension tests passed"