diff --git a/README.md b/README.md
index 42a90fa..53bd462 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/install.sh b/install.sh
index 3eecec7..12440e8 100755
--- a/install.sh
+++ b/install.sh
@@ -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
@@ -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=""
@@ -30,10 +35,21 @@ 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
@@ -41,7 +57,7 @@ else
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
@@ -49,10 +65,87 @@ else
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
+
+
+
+
+ Label
+ io.opencode.ntfy
+
+ ProgramArguments
+
+ $NODE_PATH
+ $SERVICE_DIR/server.js
+
+
+ RunAtLoad
+
+
+ KeepAlive
+
+
+ StandardOutPath
+ $HOME/.local/share/opencode-ntfy/opencode-ntfy.log
+
+ StandardErrorPath
+ $HOME/.local/share/opencode-ntfy/opencode-ntfy.log
+
+ WorkingDirectory
+ $SERVICE_DIR
+
+
+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 ""
@@ -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 ""
diff --git a/plugin/index.js b/plugin/index.js
index db8720a..7a38df5 100644
--- a/plugin/index.js
+++ b/plugin/index.js
@@ -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()
@@ -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) {
@@ -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()
+ }
+ },
}
}
diff --git a/plugin/service-client.js b/plugin/service-client.js
new file mode 100644
index 0000000..adc0329
--- /dev/null
+++ b/plugin/service-client.js
@@ -0,0 +1,194 @@
+// Service client for plugin-to-service IPC
+// Implements Issue #13: Separate callback server as brew service
+//
+// This module connects to the standalone callback service via Unix socket
+// and handles:
+// - Session registration
+// - Nonce requests for permission notifications
+// - Permission response callbacks
+
+import { createConnection } from 'net'
+
+// Default socket path (same as service)
+const DEFAULT_SOCKET_PATH = '/tmp/opencode-ntfy.sock'
+
+// Connection state
+let socket = null
+let sessionId = null
+let permissionHandler = null
+let pendingNonceRequests = new Map() // permissionId -> resolve/reject
+
+/**
+ * Check if connected to the service
+ * @returns {boolean} True if connected
+ */
+export function isConnected() {
+ return socket !== null && !socket.destroyed
+}
+
+/**
+ * Set the handler for permission responses
+ * @param {Function} handler - Called with (permissionId, response) when permission response received
+ */
+export function setPermissionHandler(handler) {
+ permissionHandler = handler
+}
+
+/**
+ * Connect to the callback service
+ * @param {Object} options
+ * @param {string} options.sessionId - OpenCode session ID
+ * @param {string} [options.socketPath] - Unix socket path (default: /tmp/opencode-ntfy.sock)
+ * @returns {Promise} True if connected successfully
+ */
+export async function connectToService(options) {
+ const socketPath = options.socketPath || DEFAULT_SOCKET_PATH
+ sessionId = options.sessionId
+
+ return new Promise((resolve) => {
+ socket = createConnection(socketPath)
+
+ let buffer = ''
+
+ socket.on('connect', () => {
+ console.log(`[opencode-ntfy] Connected to service at ${socketPath}`)
+
+ // Register session
+ sendMessage({
+ type: 'register',
+ sessionId,
+ })
+ })
+
+ socket.on('data', (data) => {
+ buffer += data.toString()
+
+ // Process complete messages (newline-delimited JSON)
+ let newlineIndex
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
+ const line = buffer.slice(0, newlineIndex)
+ buffer = buffer.slice(newlineIndex + 1)
+
+ if (!line.trim()) continue
+
+ try {
+ const message = JSON.parse(line)
+ handleMessage(message, resolve)
+ } catch (error) {
+ console.warn(`[opencode-ntfy] Invalid message from service: ${error.message}`)
+ }
+ }
+ })
+
+ socket.on('error', (err) => {
+ if (err.code === 'ECONNREFUSED' || err.code === 'ENOENT') {
+ console.warn(`[opencode-ntfy] Service not running (${err.code})`)
+ } else {
+ console.warn(`[opencode-ntfy] Socket error: ${err.message}`)
+ }
+ socket = null
+ resolve(false)
+ })
+
+ socket.on('close', () => {
+ console.log('[opencode-ntfy] Disconnected from service')
+ socket = null
+
+ // Reject any pending nonce requests
+ for (const [permissionId, { reject }] of pendingNonceRequests) {
+ reject(new Error('Service connection closed'))
+ }
+ pendingNonceRequests.clear()
+ })
+ })
+}
+
+/**
+ * Disconnect from the callback service
+ */
+export async function disconnectFromService() {
+ if (socket) {
+ socket.destroy()
+ socket = null
+ }
+ sessionId = null
+}
+
+/**
+ * Request a nonce from the service for a permission request
+ * @param {string} permissionId - Permission request ID
+ * @returns {Promise} The generated nonce
+ */
+export function requestNonce(permissionId) {
+ return new Promise((resolve, reject) => {
+ if (!isConnected()) {
+ reject(new Error('Not connected to service'))
+ return
+ }
+
+ // Store pending request
+ pendingNonceRequests.set(permissionId, { resolve, reject })
+
+ // Send request
+ sendMessage({
+ type: 'create_nonce',
+ sessionId,
+ permissionId,
+ })
+
+ // Timeout after 5 seconds
+ setTimeout(() => {
+ if (pendingNonceRequests.has(permissionId)) {
+ pendingNonceRequests.delete(permissionId)
+ reject(new Error('Nonce request timed out'))
+ }
+ }, 5000)
+ })
+}
+
+/**
+ * Send a message to the service
+ * @param {Object} message - Message to send
+ */
+function sendMessage(message) {
+ if (socket && !socket.destroyed) {
+ socket.write(JSON.stringify(message) + '\n')
+ }
+}
+
+/**
+ * Handle a message from the service
+ * @param {Object} message - Parsed message
+ * @param {Function} [connectResolve] - Resolve function for connection promise
+ */
+function handleMessage(message, connectResolve) {
+ switch (message.type) {
+ case 'registered':
+ console.log(`[opencode-ntfy] Session registered: ${message.sessionId}`)
+ if (connectResolve) {
+ connectResolve(true)
+ }
+ break
+
+ case 'nonce_created':
+ // Resolve pending nonce request
+ const pending = pendingNonceRequests.get(message.permissionId)
+ if (pending) {
+ pendingNonceRequests.delete(message.permissionId)
+ pending.resolve(message.nonce)
+ }
+ break
+
+ case 'permission_response':
+ // Forward to handler
+ if (permissionHandler) {
+ permissionHandler(message.permissionId, message.response)
+ } else {
+ console.warn(`[opencode-ntfy] Received permission response but no handler set`)
+ }
+ break
+
+ default:
+ console.warn(`[opencode-ntfy] Unknown message type from service: ${message.type}`)
+ }
+}
diff --git a/service/io.opencode.ntfy.plist b/service/io.opencode.ntfy.plist
new file mode 100644
index 0000000..4ec7a15
--- /dev/null
+++ b/service/io.opencode.ntfy.plist
@@ -0,0 +1,29 @@
+
+
+
+
+ Label
+ io.opencode.ntfy
+
+ ProgramArguments
+
+ /usr/local/bin/node
+ /usr/local/opt/opencode-ntfy/libexec/server.js
+
+
+ RunAtLoad
+
+
+ KeepAlive
+
+
+ StandardOutPath
+ /usr/local/var/log/opencode-ntfy.log
+
+ StandardErrorPath
+ /usr/local/var/log/opencode-ntfy.log
+
+ WorkingDirectory
+ /usr/local/opt/opencode-ntfy/libexec
+
+
diff --git a/service/server.js b/service/server.js
new file mode 100644
index 0000000..91e4e84
--- /dev/null
+++ b/service/server.js
@@ -0,0 +1,391 @@
+// Standalone callback server for opencode-ntfy
+// Implements Issue #13: Separate callback server as brew service
+//
+// This service runs persistently via brew services and handles:
+// - HTTP callbacks from ntfy action buttons
+// - Unix socket IPC for plugin communication
+// - Nonce management for permission requests
+// - Session registration and response forwarding
+
+import { createServer as createHttpServer } from 'http'
+import { createServer as createNetServer } from 'net'
+import { randomUUID } from 'crypto'
+import { existsSync, unlinkSync, realpathSync } from 'fs'
+import { fileURLToPath } from 'url'
+
+// Default configuration
+const DEFAULT_HTTP_PORT = 4097
+const DEFAULT_SOCKET_PATH = '/tmp/opencode-ntfy.sock'
+
+// Nonce storage: nonce -> { sessionId, permissionId, createdAt }
+const nonces = new Map()
+const NONCE_TTL_MS = 60 * 60 * 1000 // 1 hour
+
+// Session storage: sessionId -> socket connection
+const sessions = new Map()
+
+// Valid response types
+const VALID_RESPONSES = ['once', 'always', 'reject']
+
+/**
+ * Create a nonce for a permission request
+ * @param {string} sessionId - OpenCode session ID
+ * @param {string} permissionId - Permission request ID
+ * @returns {string} The generated nonce
+ */
+function createNonce(sessionId, permissionId) {
+ const nonce = randomUUID()
+ nonces.set(nonce, {
+ sessionId,
+ permissionId,
+ createdAt: Date.now(),
+ })
+ return nonce
+}
+
+/**
+ * Consume a nonce, returning its data if valid
+ * @param {string} nonce - The nonce to consume
+ * @returns {Object|null} { sessionId, permissionId } or null if invalid/expired
+ */
+function consumeNonce(nonce) {
+ const data = nonces.get(nonce)
+ if (!data) return null
+
+ nonces.delete(nonce)
+
+ if (Date.now() - data.createdAt > NONCE_TTL_MS) {
+ return null
+ }
+
+ return {
+ sessionId: data.sessionId,
+ permissionId: data.permissionId,
+ }
+}
+
+/**
+ * Clean up expired nonces
+ * @returns {number} Number of expired nonces removed
+ */
+function cleanupNonces() {
+ const now = Date.now()
+ let removed = 0
+
+ for (const [nonce, data] of nonces) {
+ if (now - data.createdAt > NONCE_TTL_MS) {
+ nonces.delete(nonce)
+ removed++
+ }
+ }
+
+ return removed
+}
+
+/**
+ * Register a session connection
+ * @param {string} sessionId - OpenCode session ID
+ * @param {net.Socket} socket - Socket connection to the plugin
+ */
+function registerSession(sessionId, socket) {
+ console.log(`[opencode-ntfy] Session registered: ${sessionId}`)
+ sessions.set(sessionId, socket)
+
+ socket.on('close', () => {
+ console.log(`[opencode-ntfy] Session disconnected: ${sessionId}`)
+ sessions.delete(sessionId)
+ })
+}
+
+/**
+ * Send a permission response to a session
+ * @param {string} sessionId - OpenCode session ID
+ * @param {string} permissionId - Permission request ID
+ * @param {string} response - Response type: 'once' | 'always' | 'reject'
+ * @returns {boolean} True if sent successfully
+ */
+function sendToSession(sessionId, permissionId, response) {
+ const socket = sessions.get(sessionId)
+ if (!socket) {
+ console.warn(`[opencode-ntfy] Session not found: ${sessionId}`)
+ return false
+ }
+
+ try {
+ const message = JSON.stringify({
+ type: 'permission_response',
+ permissionId,
+ response,
+ })
+ socket.write(message + '\n')
+ return true
+ } catch (error) {
+ console.error(`[opencode-ntfy] Failed to send to session ${sessionId}: ${error.message}`)
+ return false
+ }
+}
+
+/**
+ * Create the HTTP callback server
+ * @param {number} port - Port to listen on
+ * @returns {http.Server} The HTTP server
+ */
+function createCallbackServer(port) {
+ const server = createHttpServer((req, res) => {
+ const url = new URL(req.url, `http://localhost:${port}`)
+
+ // GET /health - Health check
+ if (req.method === 'GET' && url.pathname === '/health') {
+ res.writeHead(200, { 'Content-Type': 'text/plain' })
+ res.end('OK')
+ return
+ }
+
+ // POST /callback - Permission response from ntfy
+ if (req.method === 'POST' && url.pathname === '/callback') {
+ const nonce = url.searchParams.get('nonce')
+ const response = url.searchParams.get('response')
+
+ // Validate required params
+ if (!nonce || !response) {
+ res.writeHead(400, { 'Content-Type': 'text/plain' })
+ res.end('Missing required parameters')
+ return
+ }
+
+ // Validate response value
+ if (!VALID_RESPONSES.includes(response)) {
+ res.writeHead(400, { 'Content-Type': 'text/plain' })
+ res.end('Invalid response value')
+ return
+ }
+
+ // Validate and consume nonce
+ const payload = consumeNonce(nonce)
+ if (!payload) {
+ res.writeHead(401, { 'Content-Type': 'text/plain' })
+ res.end('Invalid or expired nonce')
+ return
+ }
+
+ // Forward to session
+ const sent = sendToSession(payload.sessionId, payload.permissionId, response)
+ if (sent) {
+ res.writeHead(200, { 'Content-Type': 'text/plain' })
+ res.end('OK')
+ } else {
+ res.writeHead(503, { 'Content-Type': 'text/plain' })
+ res.end('Session not connected')
+ }
+ return
+ }
+
+ // Unknown route
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
+ res.end('Not found')
+ })
+
+ server.on('error', (err) => {
+ console.error(`[opencode-ntfy] HTTP server error: ${err.message}`)
+ })
+
+ return server
+}
+
+/**
+ * Create the Unix socket server for IPC
+ * @param {string} socketPath - Path to the socket file
+ * @returns {net.Server} The socket server
+ */
+function createSocketServer(socketPath) {
+ const server = createNetServer((socket) => {
+ console.log('[opencode-ntfy] Plugin connected')
+
+ let buffer = ''
+
+ socket.on('data', (data) => {
+ buffer += data.toString()
+
+ // Process complete messages (newline-delimited JSON)
+ let newlineIndex
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
+ const line = buffer.slice(0, newlineIndex)
+ buffer = buffer.slice(newlineIndex + 1)
+
+ if (!line.trim()) continue
+
+ try {
+ const message = JSON.parse(line)
+ handleSocketMessage(socket, message)
+ } catch (error) {
+ console.warn(`[opencode-ntfy] Invalid message: ${error.message}`)
+ }
+ }
+ })
+
+ socket.on('error', (err) => {
+ console.warn(`[opencode-ntfy] Socket error: ${err.message}`)
+ })
+ })
+
+ server.on('error', (err) => {
+ console.error(`[opencode-ntfy] Socket server error: ${err.message}`)
+ })
+
+ return server
+}
+
+/**
+ * Handle a message from a plugin
+ * @param {net.Socket} socket - The socket connection
+ * @param {Object} message - The parsed message
+ */
+function handleSocketMessage(socket, message) {
+ switch (message.type) {
+ case 'register':
+ if (message.sessionId) {
+ registerSession(message.sessionId, socket)
+ socket.write(JSON.stringify({ type: 'registered', sessionId: message.sessionId }) + '\n')
+ }
+ break
+
+ case 'create_nonce':
+ if (message.sessionId && message.permissionId) {
+ const nonce = createNonce(message.sessionId, message.permissionId)
+ socket.write(JSON.stringify({ type: 'nonce_created', nonce, permissionId: message.permissionId }) + '\n')
+ }
+ break
+
+ default:
+ console.warn(`[opencode-ntfy] Unknown message type: ${message.type}`)
+ }
+}
+
+/**
+ * Start the callback service
+ * @param {Object} config - Configuration options
+ * @param {number} [config.httpPort] - HTTP server port (default: 4097)
+ * @param {string} [config.socketPath] - Unix socket path (default: /tmp/opencode-ntfy.sock)
+ * @returns {Promise