From bd35e2d9d670d8609863a872e3a3eba055568f12 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Wed, 17 Dec 2025 10:50:31 +0000 Subject: [PATCH] Add restart command, fix post-update restarts as well. --- .goreleaser.yaml | 3 + Makefile | 3 + internal/update/update.go | 117 ++++++++++++++++++++++++----------- internal/worker/handlers.go | 28 ++++++++- internal/worker/service.go | 3 + plugin/commands/restart.md | 21 +++++++ scripts/install.sh | 7 +++ scripts/register-plugin.sh | 1 + ui/package-lock.json | 4 +- ui/package.json | 2 +- ui/src/App.vue | 26 +++++++- ui/src/composables/useSSE.ts | 33 ++++++++++ 12 files changed, 206 insertions(+), 42 deletions(-) create mode 100644 plugin/commands/restart.md diff --git a/.goreleaser.yaml b/.goreleaser.yaml index f5bbf29..61d9795 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -266,6 +266,9 @@ archives: - src: plugin/hooks/hooks.json dst: hooks strip_parent: true + - src: plugin/commands/* + dst: commands + strip_parent: true format_overrides: - goos: windows formats: diff --git a/Makefile b/Makefile index 683171f..24f97b3 100644 --- a/Makefile +++ b/Makefile @@ -138,10 +138,13 @@ install: build stop-worker @# Install to marketplaces directory (for direct installs) @mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks @mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin + @mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands cp $(BUILD_DIR)/worker $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/ cp $(BUILD_DIR)/mcp-server $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/ cp $(BUILD_DIR)/hooks/* $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/ cp $(PLUGIN_DIR)/hooks/hooks.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/ + @# Copy slash commands if they exist + @if [ -d "$(PLUGIN_DIR)/commands" ]; then cp -r $(PLUGIN_DIR)/commands/* $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands/ 2>/dev/null || true; fi @# Update plugin.json and marketplace.json with current version to prevent stale version directories @sed 's/"version": "[^"]*"/"version": "$(VERSION)"/g' $(PLUGIN_DIR)/.claude-plugin/plugin.json > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/plugin.json @sed 's/"version": "[^"]*"/"version": "$(VERSION)"/g' $(PLUGIN_DIR)/.claude-plugin/marketplace.json > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/marketplace.json diff --git a/internal/update/update.go b/internal/update/update.go index 90e3377..c185aa8 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -469,57 +469,100 @@ func (u *Updater) extractTarGz(archivePath, destDir string) error { } func (u *Updater) replaceBinaries(extractDir string) error { - // Files to replace - binaries := []struct { - src string - dest string - }{ - {"worker", filepath.Join(u.installDir, "worker")}, - {"mcp-server", filepath.Join(u.installDir, "mcp-server")}, - {"hooks/session-start", filepath.Join(u.installDir, "hooks", "session-start")}, - {"hooks/user-prompt", filepath.Join(u.installDir, "hooks", "user-prompt")}, - {"hooks/post-tool-use", filepath.Join(u.installDir, "hooks", "post-tool-use")}, - {"hooks/stop", filepath.Join(u.installDir, "hooks", "stop")}, - {"hooks/subagent-stop", filepath.Join(u.installDir, "hooks", "subagent-stop")}, + // Binary files to replace + binaryFiles := []string{ + "worker", + "mcp-server", + "hooks/session-start", + "hooks/user-prompt", + "hooks/post-tool-use", + "hooks/stop", + "hooks/subagent-stop", + "hooks/statusline", } - for _, b := range binaries { - src := filepath.Join(extractDir, b.src) - if _, err := os.Stat(src); os.IsNotExist(err) { - continue // Skip if not in archive - } + // Get all install directories (marketplaces and cache directories) + installDirs := u.getInstallDirectories() - // Backup existing binary - if _, err := os.Stat(b.dest); err == nil { - backup := b.dest + ".bak" - if err := os.Rename(b.dest, backup); err != nil { - return fmt.Errorf("failed to backup %s: %w", b.dest, err) + for _, installDir := range installDirs { + log.Debug().Str("dir", installDir).Msg("Installing binaries to directory") + + for _, binaryFile := range binaryFiles { + src := filepath.Join(extractDir, binaryFile) + if _, err := os.Stat(src); os.IsNotExist(err) { + continue // Skip if not in archive } - defer func(backup, dest string) { - // Clean up backup on success, restore on failure - if _, err := os.Stat(dest); err == nil { - _ = os.Remove(backup) + + dest := filepath.Join(installDir, binaryFile) + + // Backup existing binary + if _, err := os.Stat(dest); err == nil { + backup := dest + ".bak" + if err := os.Rename(dest, backup); err != nil { + log.Warn().Err(err).Str("file", dest).Msg("Failed to backup, continuing anyway") } else { - _ = os.Rename(backup, dest) + defer func(backup, dest string) { + // Clean up backup on success, restore on failure + if _, err := os.Stat(dest); err == nil { + _ = os.Remove(backup) + } else { + _ = os.Rename(backup, dest) + } + }(backup, dest) } - }(backup, b.dest) - } + } - // Copy new binary - if err := copyFile(src, b.dest); err != nil { - return fmt.Errorf("failed to install %s: %w", b.dest, err) - } + // Copy new binary + if err := copyFile(src, dest); err != nil { + return fmt.Errorf("failed to install %s: %w", dest, err) + } - // Make executable - // #nosec G302 -- executables require 0755 permissions - if err := os.Chmod(b.dest, 0755); err != nil { - return fmt.Errorf("failed to chmod %s: %w", b.dest, err) + // Make executable + // #nosec G302 -- executables require 0755 permissions + if err := os.Chmod(dest, 0755); err != nil { + return fmt.Errorf("failed to chmod %s: %w", dest, err) + } } } return nil } +// getInstallDirectories returns all directories where binaries should be installed. +// This includes the marketplaces directory and any cache directories. +func (u *Updater) getInstallDirectories() []string { + dirs := []string{u.installDir} + + // Also check cache directories where Claude Code looks for plugins + home, err := os.UserHomeDir() + if err != nil { + return dirs + } + + // Look for cache directories under ~/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/ + cacheBase := filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic") + entries, err := os.ReadDir(cacheBase) + if err != nil { + return dirs + } + + for _, entry := range entries { + if entry.IsDir() { + cacheDir := filepath.Join(cacheBase, entry.Name()) + // Only add if it's different from installDir and contains a worker binary + if cacheDir != u.installDir { + workerPath := filepath.Join(cacheDir, "worker") + if _, err := os.Stat(workerPath); err == nil { + dirs = append(dirs, cacheDir) + log.Debug().Str("dir", cacheDir).Msg("Found cache directory to update") + } + } + } + } + + return dirs +} + func copyFile(src, dst string) error { // Ensure parent directory exists if err := os.MkdirAll(filepath.Dir(dst), 0750); err != nil { diff --git a/internal/worker/handlers.go b/internal/worker/handlers.go index 926027e..1aa7c3c 100644 --- a/internal/worker/handlers.go +++ b/internal/worker/handlers.go @@ -1024,7 +1024,7 @@ func (s *Service) handleSelfCheck(w http.ResponseWriter, r *http.Request) { }) } -// handleUpdateRestart restarts the worker with the new binary. +// handleUpdateRestart restarts the worker with the new binary (after update). func (s *Service) handleUpdateRestart(w http.ResponseWriter, r *http.Request) { status := s.updater.GetStatus() if status.State != "done" { @@ -1050,3 +1050,29 @@ func (s *Service) handleUpdateRestart(w http.ResponseWriter, r *http.Request) { } }() } + +// handleRestart restarts the worker process (general restart, not tied to update). +func (s *Service) handleRestart(w http.ResponseWriter, r *http.Request) { + log.Info().Msg("Manual restart requested via API") + + // Send response before restarting + writeJSON(w, map[string]interface{}{ + "success": true, + "message": "Restarting worker...", + "version": s.version, + }) + + // Flush the response + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + // Restart in background after response is sent + go func() { + // Small delay to ensure response is sent + time.Sleep(100 * time.Millisecond) + if err := s.updater.Restart(); err != nil { + log.Error().Err(err).Msg("Failed to restart worker") + } + }() +} diff --git a/internal/worker/service.go b/internal/worker/service.go index f3f0f02..95e07f5 100644 --- a/internal/worker/service.go +++ b/internal/worker/service.go @@ -635,6 +635,9 @@ func (s *Service) setupRoutes() { s.router.Get("/api/update/status", s.handleUpdateStatus) s.router.Post("/api/update/restart", s.handleUpdateRestart) + // General restart endpoint (works before DB is ready) + s.router.Post("/api/restart", s.handleRestart) + // Selfcheck endpoint (works before DB is ready - checks all components) s.router.Get("/api/selfcheck", s.handleSelfCheck) diff --git a/plugin/commands/restart.md b/plugin/commands/restart.md new file mode 100644 index 0000000..5de0806 --- /dev/null +++ b/plugin/commands/restart.md @@ -0,0 +1,21 @@ +# Restart Claude Mnemonic Worker + +Restart the claude-mnemonic worker process. Use this command when experiencing issues with the memory system. + +## Instructions + +1. Call the restart API endpoint using curl: + ```bash + curl -X POST http://127.0.0.1:37777/api/restart + ``` + +2. Wait a moment for the worker to restart (typically 1-2 seconds) + +3. Verify the worker is running by checking the version: + ```bash + curl -s http://127.0.0.1:37777/api/version + ``` + +4. Report the result to the user, including the version number from the response. + +If the restart fails, suggest the user check `/tmp/claude-mnemonic-worker.log` for errors. diff --git a/scripts/install.sh b/scripts/install.sh index 00d7400..ac0ca7e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -176,6 +176,7 @@ download_release() { info "Installing to ${INSTALL_DIR}..." mkdir -p "$INSTALL_DIR/hooks" mkdir -p "$INSTALL_DIR/.claude-plugin" + mkdir -p "$INSTALL_DIR/commands" # Copy binaries cp "$tmp_dir/worker" "$INSTALL_DIR/" @@ -185,6 +186,11 @@ download_release() { # Copy plugin configuration cp "$tmp_dir/.claude-plugin/"* "$INSTALL_DIR/.claude-plugin/" + # Copy slash commands if they exist in the release + if [[ -d "$tmp_dir/commands" ]]; then + cp -r "$tmp_dir/commands/"* "$INSTALL_DIR/commands/" 2>/dev/null || true + fi + # Make binaries executable chmod +x "$INSTALL_DIR/worker" chmod +x "$INSTALL_DIR/mcp-server" @@ -230,6 +236,7 @@ register_plugin() { # Copy files to cache directory mkdir -p "$cache_path/.claude-plugin" mkdir -p "$cache_path/hooks" + mkdir -p "$cache_path/commands" cp -r "$INSTALL_DIR/"* "$cache_path/" 2>/dev/null || true # Register in installed_plugins.json diff --git a/scripts/register-plugin.sh b/scripts/register-plugin.sh index c0961da..8e1d3b0 100755 --- a/scripts/register-plugin.sh +++ b/scripts/register-plugin.sh @@ -45,6 +45,7 @@ if command -v jq &> /dev/null; then # Ensure cache directory exists and copy plugin files mkdir -p "$CACHE_PATH/.claude-plugin" mkdir -p "$CACHE_PATH/hooks" + mkdir -p "$CACHE_PATH/commands" # Copy files from marketplace to cache cp -r "$MARKETPLACE_PATH/"* "$CACHE_PATH/" 2>/dev/null || true diff --git a/ui/package-lock.json b/ui/package-lock.json index 04b54ad..4f3baa0 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-mnemonic-dashboard", - "version": "v0.6.11-2-g8ff0873-dirty", + "version": "v0.6.15-2-gd8a4939-dirty", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-mnemonic-dashboard", - "version": "v0.6.11-2-g8ff0873-dirty", + "version": "v0.6.15-2-gd8a4939-dirty", "dependencies": { "vue": "^3.5.13" }, diff --git a/ui/package.json b/ui/package.json index 4911d6b..b211444 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "claude-mnemonic-dashboard", - "version": "v0.6.11-2-g8ff0873-dirty", + "version": "v0.6.15-2-gd8a4939-dirty", "private": true, "type": "module", "scripts": { diff --git a/ui/src/App.vue b/ui/src/App.vue index 5cfe4df..f9845b3 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -7,7 +7,7 @@ import Timeline from '@/components/Timeline.vue' import Sidebar from '@/components/Sidebar.vue' // Composables -const { isConnected, isProcessing, queueDepth } = useSSE() +const { isConnected, isReconnecting, reconnectCountdown, isProcessing, queueDepth } = useSSE() const { stats } = useStats() const { updateInfo, updateStatus, isUpdating, applyUpdate } = useUpdate() const { health } = useHealth() @@ -33,6 +33,17 @@ const { + + diff --git a/ui/src/composables/useSSE.ts b/ui/src/composables/useSSE.ts index 5d73b6d..72b1575 100644 --- a/ui/src/composables/useSSE.ts +++ b/ui/src/composables/useSSE.ts @@ -3,12 +3,15 @@ import type { SSEEvent } from '@/types' // Singleton state - shared across all useSSE() calls const isConnected = ref(false) +const isReconnecting = ref(false) +const reconnectCountdown = ref(0) const isProcessing = ref(false) const queueDepth = ref(0) const lastEvent = ref(null) let eventSource: EventSource | null = null let reconnectTimeout: number | null = null +let countdownInterval: number | null = null let connectionCount = 0 let reconnectAttempt = 0 @@ -24,6 +27,28 @@ function getBackoffDelay(): number { return Math.floor(baseDelay + jitter) } +function startCountdown(delayMs: number) { + reconnectCountdown.value = Math.ceil(delayMs / 1000) + if (countdownInterval) { + clearInterval(countdownInterval) + } + countdownInterval = window.setInterval(() => { + reconnectCountdown.value = Math.max(0, reconnectCountdown.value - 1) + if (reconnectCountdown.value <= 0 && countdownInterval) { + clearInterval(countdownInterval) + countdownInterval = null + } + }, 1000) +} + +function stopCountdown() { + if (countdownInterval) { + clearInterval(countdownInterval) + countdownInterval = null + } + reconnectCountdown.value = 0 +} + export function useSSE() { const connect = () => { @@ -36,6 +61,8 @@ export function useSSE() { eventSource.onopen = () => { isConnected.value = true + isReconnecting.value = false + stopCountdown() reconnectAttempt = 0 // Reset backoff on successful connection console.log('[SSE] Connected') } @@ -62,6 +89,7 @@ export function useSSE() { eventSource.onerror = () => { isConnected.value = false + isReconnecting.value = true eventSource?.close() eventSource = null @@ -70,6 +98,7 @@ export function useSSE() { reconnectAttempt++ console.log(`[SSE] Reconnecting in ${Math.round(delay/1000)}s (attempt ${reconnectAttempt})`) + startCountdown(delay) reconnectTimeout = window.setTimeout(() => { connect() }, delay) @@ -81,11 +110,13 @@ export function useSSE() { clearTimeout(reconnectTimeout) reconnectTimeout = null } + stopCountdown() if (eventSource) { eventSource.close() eventSource = null } isConnected.value = false + isReconnecting.value = false } // Handle page unload/refresh to ensure SSE connection is closed immediately @@ -132,6 +163,8 @@ export function useSSE() { return { isConnected, + isReconnecting, + reconnectCountdown, isProcessing, queueDepth, lastEvent,