From 0106635a35e83ce42507e8ff2fcc8c1f34785796 Mon Sep 17 00:00:00 2001 From: Esity Date: Fri, 10 Jul 2026 16:27:36 -0500 Subject: [PATCH 1/5] fix: Claude Code install via Homebrew and increase relaunch delay Replace the npm install hint for Claude Code with a proper install button using `brew install anthropics/tap/claude-code`. Increase the self-update relaunch delay from 1s to 3s to ensure the old process fully exits before reopening. --- CHANGELOG.md | 8 ++++++++ Sources/ClientsTab.swift | 23 ++++++++++++++++------- Sources/UpdateManager.swift | 4 ++-- VERSION | 2 +- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb0937d..1b47d32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [2.3.8] - 2026-07-10 + +### Fixed +- **Self-update relaunch timing** — Increased the relaunch delay from 1s to 3s after termination to ensure the old process fully exits before the new binary opens. + +### Changed +- **Claude Code install button** — Replaced the passive `npm i -g` hint with a proper "install" button using Homebrew (`brew install anthropics/tap/claude-code`), matching the Codex and Kai install UX. + ## [2.3.6] - 2026-06-23 ### Fixed diff --git a/Sources/ClientsTab.swift b/Sources/ClientsTab.swift index 5c51046..91e2ac4 100644 --- a/Sources/ClientsTab.swift +++ b/Sources/ClientsTab.swift @@ -151,7 +151,9 @@ struct ClientsTab: View { openTerminalWithCommand("claude") } } else { - installHintText("npm i -g @anthropic-ai/claude-code") + TerminalActionButton(label: "install", color: TerminalTheme.accent) { + installClaudeCode() + } } } .padding(12) @@ -335,12 +337,19 @@ struct ClientsTab: View { } } - private func installHintText(_ hint: String) -> some View { - Text(hint) - .font(.system(size: 9, design: .monospaced)) - .foregroundColor(TerminalTheme.textDim.opacity(0.5)) - .lineLimit(1) - .truncationMode(.tail) + private func installClaudeCode() { + let brew = ServiceManager.shared.resolvedBrewPathPublic + Task.detached { + let process = Process() + process.executableURL = URL(fileURLWithPath: brew) + process.arguments = ["install", "anthropics/tap/claude-code"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try? process.run() + process.waitUntilExit() + let success = process.terminationStatus == 0 + await MainActor.run { if success { claudeInstalled = true } } + } } // MARK: - Install Helpers diff --git a/Sources/UpdateManager.swift b/Sources/UpdateManager.swift index c716ca8..f357482 100644 --- a/Sources/UpdateManager.swift +++ b/Sources/UpdateManager.swift @@ -127,7 +127,7 @@ class UpdateManager: ObservableObject { let bundlePath = Bundle.main.bundlePath let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = ["-c", "sleep 1 && open '\(bundlePath)'"] + process.arguments = ["-c", "sleep 3 && open '\(bundlePath)'"] process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice try? process.run() @@ -241,7 +241,7 @@ class UpdateManager: ObservableObject { let bundlePath = Bundle.main.bundlePath let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = ["-c", "sleep 1 && open '\(bundlePath)'"] + process.arguments = ["-c", "sleep 3 && open '\(bundlePath)'"] process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice try? process.run() diff --git a/VERSION b/VERSION index e75da3e..bc4abe8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.3.6 +2.3.8 From afdbe85d17c730a608b41e7d60fd493c3a021ea2 Mon Sep 17 00:00:00 2001 From: Esity Date: Fri, 10 Jul 2026 16:38:14 -0500 Subject: [PATCH 2/5] fix: use `legionio update` for gem updates instead of per-gem legion-gem calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-gem approach had no dependency ordering and required separate brew upgrade + daemon restart logic. Now runs `legionio update` which handles the full graph. Daemon restart removed — legionio update does not affect the running process. --- CHANGELOG.md | 9 +++++---- Sources/UpdateManager.swift | 26 +++++++------------------- VERSION | 2 +- 3 files changed, 13 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b47d32..7c3264d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,14 @@ # Changelog -## [2.3.8] - 2026-07-10 - -### Fixed -- **Self-update relaunch timing** — Increased the relaunch delay from 1s to 3s after termination to ensure the old process fully exits before the new binary opens. +## [2.3.9] - 2026-07-10 ### Changed +- **Gem updates use `legionio update`** — Instead of per-gem `legion-gem update `, clicking update for any legion-* or lex-* gem now runs `legionio update` which handles the full dependency graph. Removed the separate `brew upgrade legionio` and daemon restart logic — `legionio update` does not affect the running daemon. - **Claude Code install button** — Replaced the passive `npm i -g` hint with a proper "install" button using Homebrew (`brew install anthropics/tap/claude-code`), matching the Codex and Kai install UX. +### Fixed +- **Self-update relaunch timing** — Increased the relaunch delay from 1s to 3s after termination to ensure the old process fully exits before the new binary opens. + ## [2.3.6] - 2026-06-23 ### Fixed diff --git a/Sources/UpdateManager.swift b/Sources/UpdateManager.swift index f357482..ccb2e6d 100644 --- a/Sources/UpdateManager.swift +++ b/Sources/UpdateManager.swift @@ -34,12 +34,14 @@ class UpdateManager: ObservableObject { private let resolvedBrewPath: String private let resolvedLegionGemPath: String + private let resolvedLegionioPath: String private var backgroundTimer: Timer? private var diskVersionTimer: Timer? private init() { resolvedBrewPath = Self.findPath("/opt/homebrew/bin/brew", fallback: "/usr/local/bin/brew") resolvedLegionGemPath = Self.findPath("/opt/homebrew/bin/legion-gem", fallback: "/usr/local/bin/legion-gem") + resolvedLegionioPath = Self.findPath("/opt/homebrew/bin/legionio", fallback: "/usr/local/bin/legionio") startBackgroundChecks() } @@ -196,9 +198,7 @@ class UpdateManager: ObservableObject { items[idx].isUpdating = true let brew = resolvedBrewPath - let legionGem = resolvedLegionGemPath - let name = item.name - let isLegionio = item.isLegionio + let legionio = resolvedLegionioPath let isInterlink = item.isInterlink Task.detached { @@ -206,12 +206,7 @@ class UpdateManager: ObservableObject { if isInterlink { success = Self.runSync(brew, arguments: ["upgrade", "legion-interlink"]) } else { - success = Self.runSync(legionGem, arguments: ["update", name]) - } - - // For legionio gem, also run brew upgrade to update the CLI binary - if success && isLegionio { - _ = Self.runSync(brew, arguments: ["upgrade", "legionio"]) + success = Self.runSync(legionio, arguments: ["update"]) } await MainActor.run { @@ -224,12 +219,6 @@ class UpdateManager: ObservableObject { } } - if success && isLegionio { - await ServiceManager.shared.restartService(.legionio) - } - - // After interlink upgrade, quit — the AppDelegate or cask postflight - // will detect the new version and relaunch. if success && isInterlink { await Self.relaunchInterlink() } @@ -255,16 +244,15 @@ class UpdateManager: ObservableObject { } } - /// Auto-update a lex-* gem silently. legion-gem keeps the old version installed. + /// Auto-update lex-* gems silently via `legionio update`. private func autoUpdateGem(_ item: UpdateItem) { guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return } items[idx].isUpdating = true - let legionGem = resolvedLegionGemPath - let name = item.name + let legionio = resolvedLegionioPath Task.detached { - let success = Self.runSync(legionGem, arguments: ["update", name]) + let success = Self.runSync(legionio, arguments: ["update"]) await MainActor.run { if success { self.items.removeAll { $0.id == item.id } diff --git a/VERSION b/VERSION index bc4abe8..5aa7c52 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.3.8 +2.3.9 From 43092fa77dfe3d2e582b8b1e0a8a2b912ec20f50 Mon Sep 17 00:00:00 2001 From: Esity Date: Fri, 10 Jul 2026 16:42:04 -0500 Subject: [PATCH 3/5] feat: restart daemon after gem updates, consolidate to single legionio update Run `brew services restart legionio` after a successful `legionio update` so the daemon picks up new code immediately. Configurable via "restart daemon" checkbox in the Updates tab (enabled by default). Consolidated update logic to run `legionio update` exactly once regardless of how many gems are outdated, instead of spawning per-item. --- CHANGELOG.md | 7 ++- Sources/UpdateManager.swift | 104 ++++++++++++++++++++++-------------- Sources/UpdatesTab.swift | 7 +++ VERSION | 2 +- 4 files changed, 77 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c3264d..b72c685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,12 @@ # Changelog -## [2.3.9] - 2026-07-10 +## [2.4.0] - 2026-07-10 + +### Added +- **Daemon restart after gem updates** — After `legionio update` completes successfully, Interlink now runs `brew services restart legionio` so the daemon picks up the new code immediately. Enabled by default with a "restart daemon" checkbox in the Updates tab header to disable. ### Changed -- **Gem updates use `legionio update`** — Instead of per-gem `legion-gem update `, clicking update for any legion-* or lex-* gem now runs `legionio update` which handles the full dependency graph. Removed the separate `brew upgrade legionio` and daemon restart logic — `legionio update` does not affect the running daemon. +- **Gem updates use `legionio update`** — Instead of per-gem `legion-gem update `, clicking update for any legion-* or lex-* gem now runs a single `legionio update` which handles the full dependency graph. Only runs once regardless of how many gems are outdated. - **Claude Code install button** — Replaced the passive `npm i -g` hint with a proper "install" button using Homebrew (`brew install anthropics/tap/claude-code`), matching the Codex and Kai install UX. ### Fixed diff --git a/Sources/UpdateManager.swift b/Sources/UpdateManager.swift index ccb2e6d..31ee809 100644 --- a/Sources/UpdateManager.swift +++ b/Sources/UpdateManager.swift @@ -31,6 +31,7 @@ class UpdateManager: ObservableObject { @Published var lastChecked: Date? @Published var checkError: String? @Published var autoUpdateLex = true + @Published var restartAfterUpdate = true private let resolvedBrewPath: String private let resolvedLegionGemPath: String @@ -184,30 +185,80 @@ class UpdateManager: ObservableObject { } if autoUpdateLex { - let lexItems = items.filter(\.isLex) - for item in lexItems { - autoUpdateGem(item) - } + autoUpdateLexGems() } } // MARK: - Update Actions func updateItem(_ item: UpdateItem) { + if item.isInterlink { + updateInterlink(item) + } else { + runLegionioUpdate() + } + } + + func updateAll() { + let hasInterlink = items.contains(where: \.isInterlink) + let hasGems = items.contains { !$0.isInterlink && !$0.isUpdating } + + if hasGems { runLegionioUpdate() } + if hasInterlink, let item = items.first(where: \.isInterlink) { + updateInterlink(item) + } + } + + /// Run `legionio update` once for all outdated gems, then optionally restart the daemon. + @Published private(set) var isRunningLegionioUpdate = false + + private func runLegionioUpdate() { + guard !isRunningLegionioUpdate else { return } + isRunningLegionioUpdate = true + + let gemIds = items.filter { !$0.isInterlink }.map(\.id) + for i in items.indices where gemIds.contains(items[i].id) { + items[i].isUpdating = true + } + + let legionio = resolvedLegionioPath + let shouldRestart = restartAfterUpdate + + Task.detached { + let success = Self.runSync(legionio, arguments: ["update"]) + + if success && shouldRestart { + await ServiceManager.shared.restartService(.legionio) + } + + await MainActor.run { + if success { + self.items.removeAll { gemIds.contains($0.id) } + } else { + for i in self.items.indices where gemIds.contains(self.items[i].id) { + self.items[i].isUpdating = false + } + } + self.isRunningLegionioUpdate = false + } + } + } + + /// Auto-update lex-* gems silently via `legionio update`. + private func autoUpdateLexGems() { + let lexItems = items.filter(\.isLex) + guard !lexItems.isEmpty else { return } + runLegionioUpdate() + } + + private func updateInterlink(_ item: UpdateItem) { guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return } items[idx].isUpdating = true let brew = resolvedBrewPath - let legionio = resolvedLegionioPath - let isInterlink = item.isInterlink Task.detached { - var success = false - if isInterlink { - success = Self.runSync(brew, arguments: ["upgrade", "legion-interlink"]) - } else { - success = Self.runSync(legionio, arguments: ["update"]) - } + let success = Self.runSync(brew, arguments: ["upgrade", "legion-interlink"]) await MainActor.run { if success { @@ -219,7 +270,7 @@ class UpdateManager: ObservableObject { } } - if success && isInterlink { + if success { await Self.relaunchInterlink() } } @@ -238,33 +289,6 @@ class UpdateManager: ObservableObject { } } - func updateAll() { - for item in items where !item.isUpdating { - updateItem(item) - } - } - - /// Auto-update lex-* gems silently via `legionio update`. - private func autoUpdateGem(_ item: UpdateItem) { - guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return } - items[idx].isUpdating = true - - let legionio = resolvedLegionioPath - - Task.detached { - let success = Self.runSync(legionio, arguments: ["update"]) - await MainActor.run { - if success { - self.items.removeAll { $0.id == item.id } - } else { - if let idx = self.items.firstIndex(where: { $0.id == item.id }) { - self.items[idx].isUpdating = false - } - } - } - } - } - // MARK: - Cask Version Check (GitHub API) /// Fetch the latest release tag from the Legion Interlink GitHub releases page. diff --git a/Sources/UpdatesTab.swift b/Sources/UpdatesTab.swift index ef76b84..4284baa 100644 --- a/Sources/UpdatesTab.swift +++ b/Sources/UpdatesTab.swift @@ -60,6 +60,13 @@ struct UpdatesTab: View { } .toggleStyle(TerminalCheckboxStyle()) + Toggle(isOn: $updateManager.restartAfterUpdate) { + Text("restart daemon") + .font(.system(size: 9, design: .monospaced)) + .foregroundColor(TerminalTheme.textDim) + } + .toggleStyle(TerminalCheckboxStyle()) + if updateManager.outdatedCount > 0 { updateAllButton } diff --git a/VERSION b/VERSION index 5aa7c52..197c4d5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.3.9 +2.4.0 From 9f73a7f2c6d15ffe737ae52122b058ecc2afbf4c Mon Sep 17 00:00:00 2001 From: Esity Date: Fri, 10 Jul 2026 16:53:23 -0500 Subject: [PATCH 4/5] feat: auto-upgrade legionio CLI, auto-update all gems, 4-hour check interval - Auto-upgrade legionio formula via `brew upgrade legionio` when outdated, with daemon restart. Configurable via "auto-upgrade cli" checkbox. - Auto-update now covers both lex-* and legion-* gems (renamed toggle to "auto-update gems"). - Check interval changed from 30 minutes to 4 hours. --- CHANGELOG.md | 5 +++++ Sources/UpdateManager.swift | 45 ++++++++++++++++++++++++++++++++----- Sources/UpdatesTab.swift | 9 +++++++- VERSION | 2 +- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b72c685..c88feea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [2.4.1] - 2026-07-10 + +### Added +- **Auto-upgrade legionio CLI** — When a newer `legionio` formula is available, Interlink automatically runs `brew upgrade legionio` and restarts the daemon. Enabled by default with an "auto-upgrade cli" checkbox in the Updates tab to disable. + ## [2.4.0] - 2026-07-10 ### Added diff --git a/Sources/UpdateManager.swift b/Sources/UpdateManager.swift index 31ee809..3d467b5 100644 --- a/Sources/UpdateManager.swift +++ b/Sources/UpdateManager.swift @@ -31,6 +31,7 @@ class UpdateManager: ObservableObject { @Published var lastChecked: Date? @Published var checkError: String? @Published var autoUpdateLex = true + @Published var autoUpgradeLegionio = true @Published var restartAfterUpdate = true private let resolvedBrewPath: String @@ -87,7 +88,7 @@ class UpdateManager: ObservableObject { // MARK: - Background Periodic Check private func startBackgroundChecks() { - backgroundTimer = Timer.scheduledTimer(withTimeInterval: 1800, repeats: true) { [weak self] _ in + backgroundTimer = Timer.scheduledTimer(withTimeInterval: 14400, repeats: true) { [weak self] _ in guard let self else { return } Task { @MainActor in await self.checkForUpdates(force: true, background: true) @@ -185,7 +186,11 @@ class UpdateManager: ObservableObject { } if autoUpdateLex { - autoUpdateLexGems() + autoUpdateGems() + } + + if autoUpgradeLegionio { + autoUpgradeLegionioBrew() } } @@ -244,13 +249,41 @@ class UpdateManager: ObservableObject { } } - /// Auto-update lex-* gems silently via `legionio update`. - private func autoUpdateLexGems() { - let lexItems = items.filter(\.isLex) - guard !lexItems.isEmpty else { return } + /// Auto-update all legion-*/lex-* gems via `legionio update`. + private func autoUpdateGems() { + let gemItems = items.filter { !$0.isInterlink && !$0.isLegionio } + guard !gemItems.isEmpty else { return } runLegionioUpdate() } + /// Auto-upgrade the legionio CLI formula via brew and restart the daemon. + private func autoUpgradeLegionioBrew() { + guard items.contains(where: \.isLegionio) else { return } + guard let idx = items.firstIndex(where: \.isLegionio) else { return } + items[idx].isUpdating = true + + let brew = resolvedBrewPath + let shouldRestart = restartAfterUpdate + + Task.detached { + let success = Self.runSync(brew, arguments: ["upgrade", "legionio"]) + + if success && shouldRestart { + await ServiceManager.shared.restartService(.legionio) + } + + await MainActor.run { + if success { + self.items.removeAll(where: \.isLegionio) + } else { + if let idx = self.items.firstIndex(where: \.isLegionio) { + self.items[idx].isUpdating = false + } + } + } + } + } + private func updateInterlink(_ item: UpdateItem) { guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return } items[idx].isUpdating = true diff --git a/Sources/UpdatesTab.swift b/Sources/UpdatesTab.swift index 4284baa..a681cea 100644 --- a/Sources/UpdatesTab.swift +++ b/Sources/UpdatesTab.swift @@ -54,7 +54,14 @@ struct UpdatesTab: View { } Toggle(isOn: $updateManager.autoUpdateLex) { - Text("auto-update lex") + Text("auto-update gems") + .font(.system(size: 9, design: .monospaced)) + .foregroundColor(TerminalTheme.textDim) + } + .toggleStyle(TerminalCheckboxStyle()) + + Toggle(isOn: $updateManager.autoUpgradeLegionio) { + Text("auto-upgrade cli") .font(.system(size: 9, design: .monospaced)) .foregroundColor(TerminalTheme.textDim) } diff --git a/VERSION b/VERSION index 197c4d5..005119b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.4.0 +2.4.1 From b4e1fd18b2555500943176cce3d7250c3674fd63 Mon Sep 17 00:00:00 2001 From: Esity Date: Fri, 10 Jul 2026 17:26:05 -0500 Subject: [PATCH 5/5] fix: Codex card detects ~/.codex, opens ChatGPT.app, renamed to Codex/ChatGPT Codex desktop app is now part of ChatGPT. Detection based on ~/.codex folder existing (shared config for both CLI and ChatGPT app). Open button launches ChatGPT.app. Install uses `brew install codex` for the CLI. --- Sources/ClientsTab.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/ClientsTab.swift b/Sources/ClientsTab.swift index 91e2ac4..4202c88 100644 --- a/Sources/ClientsTab.swift +++ b/Sources/ClientsTab.swift @@ -69,9 +69,7 @@ struct ClientsTab: View { fm.isExecutableFile(atPath: "/usr/local/bin/claude") || fm.isExecutableFile(atPath: "\(home)/.local/bin/claude") - codexInstalled = - fm.fileExists(atPath: "/Applications/Codex.app") || - fm.fileExists(atPath: "\(home)/Applications/Codex.app") + codexInstalled = fm.fileExists(atPath: "\(home)/.codex") kaiInstalled = fm.fileExists(atPath: "/Applications/Kai.app") || @@ -171,7 +169,7 @@ struct ClientsTab: View { ) VStack(alignment: .leading, spacing: 4) { - Text("Codex") + Text("Codex/ChatGPT") .font(.system(size: 13, weight: .medium, design: .monospaced)) .foregroundColor(TerminalTheme.text) @@ -187,7 +185,7 @@ struct ClientsTab: View { if codexInstalled { routingToggle(enabled: $codexRoutingEnabled, active: codexRouted) TerminalActionButton(label: "open", color: TerminalTheme.green) { - NSWorkspace.shared.open(URL(fileURLWithPath: "/Applications/Codex.app")) + openCodex() } } else { TerminalActionButton(label: "install", color: TerminalTheme.accent) { @@ -369,12 +367,16 @@ struct ClientsTab: View { } } + private func openCodex() { + NSWorkspace.shared.open(URL(fileURLWithPath: "/Applications/ChatGPT.app")) + } + private func installCodex() { let brew = ServiceManager.shared.resolvedBrewPathPublic Task.detached { let process = Process() process.executableURL = URL(fileURLWithPath: brew) - process.arguments = ["install", "--cask", "codex"] + process.arguments = ["install", "codex"] process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice try? process.run()