diff --git a/Dockerfile b/Dockerfile index 69362ae..7809e76 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,8 +46,9 @@ COPY --from=frontend-builder /app/frontend-modern/dist ./internal/api/frontend-m # Build the binaries with embedded frontend RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ --mount=type=cache,id=pulse-go-build,target=/root/.cache/go-build \ + VERSION="v$(cat VERSION | tr -d '\n')" && \ CGO_ENABLED=0 GOOS=linux go build \ - -ldflags="-s -w" \ + -ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=${VERSION}" \ -trimpath \ -o pulse ./cmd/pulse @@ -81,44 +82,48 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ # Build host-agent binaries for all platforms (for download endpoint) RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ --mount=type=cache,id=pulse-go-build,target=/root/.cache/go-build \ + VERSION="v$(cat VERSION | tr -d '\n')" && \ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ - -ldflags="-s -w" \ + -ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \ -trimpath \ -o pulse-host-agent-linux-amd64 ./cmd/pulse-host-agent && \ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build \ - -ldflags="-s -w" \ + -ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \ -trimpath \ -o pulse-host-agent-linux-arm64 ./cmd/pulse-host-agent && \ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build \ - -ldflags="-s -w" \ + -ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \ -trimpath \ -o pulse-host-agent-linux-armv7 ./cmd/pulse-host-agent && \ CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build \ - -ldflags="-s -w" \ + -ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \ -trimpath \ -o pulse-host-agent-darwin-amd64 ./cmd/pulse-host-agent && \ CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build \ - -ldflags="-s -w" \ + -ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \ -trimpath \ -o pulse-host-agent-darwin-arm64 ./cmd/pulse-host-agent && \ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build \ - -ldflags="-s -w" \ + -ldflags="-s -w -X github.com/rcourtman/pulse-go-rewrite/internal/hostagent.Version=${VERSION}" \ -trimpath \ -o pulse-host-agent-windows-amd64.exe ./cmd/pulse-host-agent # Build pulse-sensor-proxy for all Linux architectures (for download endpoint) RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ --mount=type=cache,id=pulse-go-build,target=/root/.cache/go-build \ + VERSION="v$(cat VERSION | tr -d '\n')" && \ + BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S') && \ + GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown') && \ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ - -ldflags="-s -w" \ + -ldflags="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${GIT_COMMIT}" \ -trimpath \ -o pulse-sensor-proxy-linux-amd64 ./cmd/pulse-sensor-proxy && \ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build \ - -ldflags="-s -w" \ + -ldflags="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${GIT_COMMIT}" \ -trimpath \ -o pulse-sensor-proxy-linux-arm64 ./cmd/pulse-sensor-proxy && \ CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build \ - -ldflags="-s -w" \ + -ldflags="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${GIT_COMMIT}" \ -trimpath \ -o pulse-sensor-proxy-linux-armv7 ./cmd/pulse-sensor-proxy && \ cp pulse-sensor-proxy-linux-amd64 pulse-sensor-proxy diff --git a/grep b/grep new file mode 100644 index 0000000..2664aeb --- /dev/null +++ b/grep @@ -0,0 +1,13 @@ +OpenAI Codex v0.55.0 (research preview) +-------- +workdir: /opt/pulse +model: gpt-5-codex +provider: openai +approval: never +sandbox: danger-full-access +reasoning effort: high +reasoning summaries: auto +session id: 019a5841-a30d-7f51-a957-771ff45a81db +-------- +user +What is 5+5? diff --git a/internal/dockeragent/version.go b/internal/dockeragent/version.go index d740c36..49a376c 100644 --- a/internal/dockeragent/version.go +++ b/internal/dockeragent/version.go @@ -2,6 +2,6 @@ package dockeragent // Version is the semantic version of the Pulse Docker agent binary. It is // overridden at build time via -ldflags for release artifacts. When building -// from source without ldflags, it defaults to this development value. -// Set to match deployed agents to prevent update loops in development. -var Version = "v4.30.0" +// from source without ldflags, it defaults to "dev" to prevent auto-update +// loops in development builds. +var Version = "dev" diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 0000000..d4deba7 --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,65 @@ +# Pulse Codex MCP Server + +An MCP (Model Context Protocol) server that provides OpenAI Codex integration for Claude Code. + +## Features + +- **codex** - Ask questions to OpenAI Codex AI assistant +- **codex_clear_session** - Clear stored session context +- Automatic session management across conversation turns +- Clean response extraction (no token counts or debug info) + +## Installation + +### 1. Build the Server + +```bash +cd /opt/pulse/mcp-server +npm install +npm run build +``` + +### 2. Add to Claude Code + +```bash +claude mcp add --transport stdio pulse-codex -- node /opt/pulse/mcp-server/dist/index.js +``` + +## Usage + +### In Claude Code Conversations + +**Ask a question:** +``` +Use the codex tool to evaluate whether we should use X or Y for Z scenario +``` + +**Continue a conversation:** +``` +Use the codex tool with conversationId "my-conversation" to dig deeper into that approach +``` + +**Start fresh:** +``` +Use the codex_clear_session tool with conversationId "my-conversation" +``` + +## How It Works + +- Each conversation is identified by a `conversationId` +- The server automatically maintains codex session IDs for each conversation +- Responses are clean (just the answer, no metadata) +- Sessions persist across multiple tool calls with the same conversationId + +## Development + +Watch mode for development: +```bash +npm run dev +``` + +## Architecture + +- **TypeScript** - Type-safe MCP server implementation +- **@modelcontextprotocol/sdk** - Official MCP SDK +- **stdio transport** - Communicates with Claude Code via standard input/output diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts new file mode 100644 index 0000000..b1a2e57 --- /dev/null +++ b/mcp-server/src/index.ts @@ -0,0 +1,150 @@ +#!/usr/bin/env node + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { writeFile, readFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { randomBytes } from "crypto"; + +const execAsync = promisify(exec); + +// Session management +const sessions = new Map(); // Map conversation ID to codex session ID + +// Create the MCP server +const server = new McpServer({ + name: "pulse-codex", + version: "1.0.0", +}); + +// Register the codex tool for initial questions +server.registerTool( + "codex", + { + title: "Codex AI Assistant", + description: "Ask a question to OpenAI Codex AI assistant. Returns a thoughtful response. Use this for architectural decisions, code review, and technical consultation.", + inputSchema: { + question: z.string().describe("The question to ask Codex"), + conversationId: z.string().optional().describe("Optional conversation ID to maintain context across calls"), + }, + }, + async ({ question, conversationId }) => { + const convId = conversationId || `conv-${Date.now()}-${process.pid}`; + const uniqueId = `${Date.now()}-${process.pid}-${randomBytes(4).toString('hex')}`; + const tempFile = join(tmpdir(), `codex-${uniqueId}.txt`); + + try { + // Check if we have an existing codex session for this conversation + const existingSessionId = sessions.get(convId); + + // Write question to a temp file to avoid shell escaping issues + const questionFile = join(tmpdir(), `codex-q-${uniqueId}.txt`); + await writeFile(questionFile, question, "utf-8"); + + let command: string; + if (existingSessionId) { + // Resume existing session + command = `codex exec --yolo -o "${tempFile}" resume "${existingSessionId}" < "${questionFile}"`; + } else { + // Start new session + command = `codex exec --yolo -o "${tempFile}" < "${questionFile}"`; + } + + // Execute codex and capture stderr for session ID only + // Note: stderr contains all reasoning output, but we only need the session ID + const { stdout, stderr } = await execAsync(command, { + timeout: 1800000, // 30 minute timeout (codex can take a very long time for complex queries) + maxBuffer: 10 * 1024 * 1024, // 10MB buffer + }); + + // Extract only the session ID from stderr + const sessionIdMatch = stderr.match(/session id:\s*([a-f0-9-]+)/); + const sessionId = sessionIdMatch ? sessionIdMatch[1] : ""; + + // Store session for this conversation + if (sessionId && !existingSessionId) { + sessions.set(convId, sessionId); + } + + // Read the response from the output file + const response = await readFile(tempFile, "utf-8"); + + return { + content: [ + { + type: "text", + text: `${response.trim()}\n\n---\nSession ID: ${sessionId}\nConversation ID: ${convId}`, + }, + ], + }; + } catch (error) { + // Log full error to stderr for debugging + console.error("Codex execution error:", error); + + // Extract only the relevant error info, not the full stderr with reasoning + let errorMessage = "Unknown error"; + if (error instanceof Error) { + // For exec errors, the message contains both stdout and stderr + // We want to extract just the actual error, not all the reasoning output + const lines = error.message.split('\n'); + // Look for actual error messages (usually start with "Error:" or contain "failed") + const relevantLines = lines.filter(line => + line.includes('Error:') || + line.includes('failed') || + line.includes('not found') || + line.includes('permission denied') || + line.includes('exit code') + ); + errorMessage = relevantLines.length > 0 ? relevantLines.join('\n') : error.message; + } + throw new Error(`Failed to execute codex: ${errorMessage}`); + } + } +); + +// Register a tool to clear session history +server.registerTool( + "codex_clear_session", + { + title: "Clear Codex Session", + description: "Clear the stored codex session for a conversation, starting fresh", + inputSchema: { + conversationId: z.string().describe("The conversation ID to clear"), + }, + }, + async ({ conversationId }) => { + const existed = sessions.has(conversationId); + sessions.delete(conversationId); + + const message = existed + ? `Session cleared for conversation ${conversationId}` + : `No session found for conversation ${conversationId}`; + + return { + content: [ + { + type: "text", + text: message, + }, + ], + }; + } +); + +// Connect to stdio transport +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + + // Log to stderr so it doesn't interfere with MCP protocol + console.error("Pulse Codex MCP Server running on stdio"); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/mcp-server/tsconfig.json b/mcp-server/tsconfig.json new file mode 100644 index 0000000..6ba4754 --- /dev/null +++ b/mcp-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022"], + "moduleResolution": "node", + "rootDir": "./src", + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/scripts/backup-claude-md.sh b/scripts/backup-claude-md.sh new file mode 100755 index 0000000..6e0f465 --- /dev/null +++ b/scripts/backup-claude-md.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Daily LOCAL backup script for CLAUDE.md +# Keeps last 30 days of backups in a LOCAL directory (never committed) + +set -e + +CLAUDE_MD="/opt/pulse/CLAUDE.md" +BACKUP_DIR="$HOME/.claude-md-backups" # User's home directory, not in repo +DATE=$(date +%Y-%m-%d) +BACKUP_FILE="${BACKUP_DIR}/CLAUDE.md.${DATE}" + +# Create backup directory if it doesn't exist +mkdir -p "$BACKUP_DIR" + +# Only create backup if CLAUDE.md exists +if [ ! -f "$CLAUDE_MD" ]; then + echo "Error: $CLAUDE_MD not found" + exit 1 +fi + +# Only create backup if one doesn't exist for today +if [ -f "$BACKUP_FILE" ]; then + echo "Backup already exists for today: $BACKUP_FILE" + exit 0 +fi + +# Create backup +cp "$CLAUDE_MD" "$BACKUP_FILE" +echo "Created backup: $BACKUP_FILE" + +# Remove backups older than 30 days +find "$BACKUP_DIR" -name "CLAUDE.md.*" -type f -mtime +30 -delete +echo "Cleaned up backups older than 30 days" + +# Show current backups +echo "" +echo "Current backups in $BACKUP_DIR:" +ls -lh "$BACKUP_DIR" | tail -n +2 | wc -l +echo "backups found" diff --git a/session id: b/session id: new file mode 100644 index 0000000..2664aeb --- /dev/null +++ b/session id: @@ -0,0 +1,13 @@ +OpenAI Codex v0.55.0 (research preview) +-------- +workdir: /opt/pulse +model: gpt-5-codex +provider: openai +approval: never +sandbox: danger-full-access +reasoning effort: high +reasoning summaries: auto +session id: 019a5841-a30d-7f51-a957-771ff45a81db +-------- +user +What is 5+5?