Skip to content
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,52 @@ export NTFY_CALLBACK_HOST=your-machine.tailnet.ts.net

### Interactive Permissions

Interactive permission notifications require `callbackHost` to be configured. Without it, only read-only notifications (idle, error, retry) are sent.
Interactive permission notifications require `callbackHost` to be configured AND the callback service to be running. Without these, only read-only notifications (idle, error, retry) are sent.

#### Starting the Callback Service

The callback service runs persistently to handle permission responses from ntfy.

**If installed via Homebrew (recommended):**

```bash
# Start the service (runs at login)
brew services start opencode-ntfy

# Check service status
brew services info opencode-ntfy

# View logs
tail -f ~/Library/Logs/Homebrew/opencode-ntfy.log

# Stop the service
brew services stop opencode-ntfy
```

**If installed manually:**

```bash
# Start the service
launchctl load ~/Library/LaunchAgents/io.opencode.ntfy.plist

# Check service status
launchctl list | grep opencode

# View logs
tail -f ~/.local/share/opencode-ntfy/opencode-ntfy.log

# Stop the service
launchctl unload ~/Library/LaunchAgents/io.opencode.ntfy.plist
```

#### Configuring Callback Access

For interactive notifications to work, your phone must be able to reach the callback server:

1. Set `callbackHost` to your machine's hostname accessible from your phone
2. For Tailscale users: use your Tailscale hostname (e.g., `macbook.tail1234.ts.net`)
3. Ensure port 4097 (or your configured `callbackPort`) is accessible
4. Start the callback service (see above)

## Notifications

Expand Down
110 changes: 103 additions & 7 deletions install.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
#
# Install opencode-ntfy plugin
# Install opencode-ntfy plugin and callback service
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/athal7/opencode-ntfy/main/install.sh | bash
Expand All @@ -14,14 +14,19 @@ set -euo pipefail
REPO="athal7/opencode-ntfy"
PLUGIN_NAME="opencode-ntfy"
PLUGIN_DIR="$HOME/.config/opencode/plugins/$PLUGIN_NAME"
SERVICE_DIR="$HOME/.local/share/opencode-ntfy"
CONFIG_FILE="$HOME/.config/opencode/opencode.json"
PLUGIN_FILES="index.js notifier.js callback.js hostname.js nonces.js"
PLIST_DIR="$HOME/Library/LaunchAgents"
PLIST_NAME="io.opencode.ntfy.plist"
PLUGIN_FILES="index.js notifier.js callback.js hostname.js nonces.js config.js service-client.js"
SERVICE_FILES="server.js"

echo "Installing $PLUGIN_NAME..."
echo ""

# Create plugin directory
# Create directories
mkdir -p "$PLUGIN_DIR"
mkdir -p "$SERVICE_DIR"

# Check if we're running from a local clone or need to download
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}" 2>/dev/null)" && pwd 2>/dev/null)" || SCRIPT_DIR=""
Expand All @@ -30,29 +35,117 @@ if [[ -n "$SCRIPT_DIR" ]] && [[ -f "$SCRIPT_DIR/plugin/index.js" ]]; then
# Local install from clone
echo "Installing from local directory..."

echo ""
echo "Plugin files:"
for file in $PLUGIN_FILES; do
if [[ -f "$SCRIPT_DIR/plugin/$file" ]]; then
cp "$SCRIPT_DIR/plugin/$file" "$PLUGIN_DIR/$file"
echo " Installed: $file"
echo " Installed: plugin/$file -> $PLUGIN_DIR/$file"
fi
done

echo ""
echo "Service files:"
for file in $SERVICE_FILES; do
if [[ -f "$SCRIPT_DIR/service/$file" ]]; then
cp "$SCRIPT_DIR/service/$file" "$SERVICE_DIR/$file"
echo " Installed: service/$file -> $SERVICE_DIR/$file"
fi
done
else
# Remote install - download from GitHub
echo "Downloading plugin files from GitHub..."

for file in $PLUGIN_FILES; do
echo " Downloading: $file"
echo " Downloading: plugin/$file"
if curl -fsSL "https://raw.githubusercontent.com/$REPO/main/plugin/$file" -o "$PLUGIN_DIR/$file"; then
echo " Installed: $file"
else
echo " ERROR: Failed to download $file"
exit 1
fi
done

echo ""
echo "Downloading service files from GitHub..."

for file in $SERVICE_FILES; do
echo " Downloading: service/$file"
if curl -fsSL "https://raw.githubusercontent.com/$REPO/main/service/$file" -o "$SERVICE_DIR/$file"; then
echo " Installed: $file"
else
echo " ERROR: Failed to download $file"
exit 1
fi
done
fi

echo ""
echo "Plugin files installed to: $PLUGIN_DIR"
echo "Service files installed to: $SERVICE_DIR"

# Install LaunchAgent plist (macOS only)
if [[ "$(uname)" == "Darwin" ]]; then
echo ""
echo "Installing LaunchAgent for callback service..."

mkdir -p "$PLIST_DIR"

# Find node path (handle both Intel and Apple Silicon Macs)
NODE_PATH=$(command -v node 2>/dev/null)
if [[ -z "$NODE_PATH" ]]; then
# Try Homebrew paths
if [[ -x "/opt/homebrew/bin/node" ]]; then
NODE_PATH="/opt/homebrew/bin/node"
elif [[ -x "/usr/local/bin/node" ]]; then
NODE_PATH="/usr/local/bin/node"
else
echo " WARNING: node not found, please install Node.js"
NODE_PATH="/usr/local/bin/node"
fi
fi

# Generate plist with correct paths
cat > "$PLIST_DIR/$PLIST_NAME" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>io.opencode.ntfy</string>

<key>ProgramArguments</key>
<array>
<string>$NODE_PATH</string>
<string>$SERVICE_DIR/server.js</string>
</array>

<key>RunAtLoad</key>
<true/>

<key>KeepAlive</key>
<true/>

<key>StandardOutPath</key>
<string>$HOME/.local/share/opencode-ntfy/opencode-ntfy.log</string>

<key>StandardErrorPath</key>
<string>$HOME/.local/share/opencode-ntfy/opencode-ntfy.log</string>

<key>WorkingDirectory</key>
<string>$SERVICE_DIR</string>
</dict>
</plist>
EOF

echo " LaunchAgent installed to: $PLIST_DIR/$PLIST_NAME"
echo ""
echo " To start the callback service:"
echo " launchctl load $PLIST_DIR/$PLIST_NAME"
echo ""
echo " To stop the callback service:"
echo " launchctl unload $PLIST_DIR/$PLIST_NAME"
fi

# Configure opencode.json
echo ""
Expand Down Expand Up @@ -144,7 +237,10 @@ echo " NTFY_TOKEN=tk_xxx # ntfy access token for protected top
echo " NTFY_CALLBACK_HOST=host.ts.net # Callback host for interactive notifications"
echo " NTFY_CALLBACK_PORT=4097 # Callback server port"
echo " NTFY_IDLE_DELAY_MS=300000 # Idle notification delay (5 min)"

echo ""
echo "For interactive permissions, ensure your phone can reach"
echo "the callback URL (e.g., via Tailscale)."
echo "For interactive permissions:"
echo " 1. Set NTFY_CALLBACK_HOST to your machine's hostname (e.g., via Tailscale)"
echo " 2. Start the callback service: launchctl load ~/Library/LaunchAgents/$PLIST_NAME"
echo " 3. Ensure your phone can reach the callback URL"
echo ""
117 changes: 113 additions & 4 deletions plugin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@
// See README.md for full configuration options.

import { basename } from 'path'
import { sendNotification } from './notifier.js'
import { randomUUID } from 'crypto'
import { sendNotification, sendPermissionNotification } from './notifier.js'
import { loadConfig } from './config.js'
import {
connectToService,
disconnectFromService,
isConnected,
requestNonce,
setPermissionHandler,
} from './service-client.js'

// Load configuration from opencode.json and environment
const config = loadConfig()
Expand All @@ -22,19 +30,67 @@ export const Notify = async ({ $, client, directory }) => {
}

console.log(`[opencode-ntfy] Initialized for topic: ${config.topic}`)

// Session ID for this plugin instance
const sessionId = randomUUID()

// Interactive mode state
let serviceConnected = false

if (config.callbackHost) {
console.log(`[opencode-ntfy] Interactive mode enabled (callback: ${config.callbackHost}:${config.callbackPort})`)

// Set up permission response handler
setPermissionHandler(async (permissionId, response) => {
console.log(`[opencode-ntfy] Permission response received: ${permissionId} -> ${response}`)

// Map response to OpenCode permission action
let action
switch (response) {
case 'once':
action = 'allow'
break
case 'always':
action = 'allowAlways'
break
case 'reject':
action = 'deny'
break
default:
console.warn(`[opencode-ntfy] Unknown response type: ${response}`)
return
}

// Submit permission response to OpenCode
try {
await client.permission.respond({
id: permissionId,
action,
})
console.log(`[opencode-ntfy] Permission ${permissionId} resolved with action: ${action}`)
} catch (error) {
console.warn(`[opencode-ntfy] Failed to respond to permission ${permissionId}: ${error.message}`)
}
})

// Connect to service
serviceConnected = await connectToService({ sessionId })
if (serviceConnected) {
console.log('[opencode-ntfy] Connected to callback service')
} else {
console.log('[opencode-ntfy] Callback service not running, interactive permissions disabled')
console.log('[opencode-ntfy] Start service with: launchctl load ~/Library/LaunchAgents/io.opencode.ntfy.plist')
}
} else {
console.log('[opencode-ntfy] Read-only mode (set callbackHost for interactive permissions)')
}

// TODO: Issue #4 - Start callback server if callbackHost is configured

const dir = basename(process.cwd())
let idleTimer = null

return {
event: async ({ event }) => {
// Handle session status events (idle notifications)
if (event.type === 'session.status') {
const status = event.properties?.status?.type
if (status === 'idle' && !idleTimer) {
Expand All @@ -54,9 +110,62 @@ export const Notify = async ({ $, client, directory }) => {
idleTimer = null
}
}
// TODO: Issue #3 - Handle permission.updated events

// Handle permission.updated events (interactive permissions)
if (event.type === 'permission.updated') {
const permission = event.properties?.permission
if (!permission || permission.status !== 'pending' || !permission.id) {
return
}

// Only send interactive notifications if service is connected
if (!config.callbackHost || !isConnected()) {
return
}

const permissionId = permission.id
const tool = permission.tool || 'Unknown tool'
const description = permission.description || 'Permission requested'

try {
// Request nonce from service
const nonce = await requestNonce(permissionId)

// Build callback URL
const callbackUrl = `http://${config.callbackHost}:${config.callbackPort}/callback`

// Send permission notification with action buttons
await sendPermissionNotification({
server: config.server,
topic: config.topic,
callbackUrl,
nonce,
tool,
description,
authToken: config.authToken,
})

console.log(`[opencode-ntfy] Permission notification sent for: ${tool}`)
} catch (error) {
console.warn(`[opencode-ntfy] Failed to send permission notification: ${error.message}`)
}
}

// TODO: Issue #7 - Handle error and retry events
},

// Cleanup on shutdown
shutdown: async () => {
if (idleTimer) {
clearTimeout(idleTimer)
idleTimer = null
}

// Use isConnected() as the source of truth (socket may have closed unexpectedly)
if (isConnected()) {
await disconnectFromService()
}
},
}
}

Expand Down
Loading