From 5740433a0e71b375f832f008e5391e15908e93c4 Mon Sep 17 00:00:00 2001 From: Kefeng Zhou Date: Sat, 18 Apr 2026 20:30:27 +0700 Subject: [PATCH 1/7] docs: v0.3.7 maintenance plan Four tasks: Node.js 24 upgrade, MLX stdout capture, HF update detection, GUI/CLI PIDFile sharing. Independent, execute in any order. Recommended: 1 -> 4 -> 2 -> 3. --- .../plans/2026-04-18-v0.3.7-maintenance.md | 762 ++++++++++++++++++ 1 file changed, 762 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-18-v0.3.7-maintenance.md diff --git a/docs/superpowers/plans/2026-04-18-v0.3.7-maintenance.md b/docs/superpowers/plans/2026-04-18-v0.3.7-maintenance.md new file mode 100644 index 0000000..c0f47a9 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-v0.3.7-maintenance.md @@ -0,0 +1,762 @@ +# v0.3.7 Maintenance Release Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Four maintenance improvements agreed after v0.3.6: GUI↔CLI state convergence, MLX stdout/stderr capture, HF model-update detection, GitHub Actions Node.js 24 upgrade. + +**Architecture:** Four independent tasks — one per subsystem. No cross-dependencies, can execute in any order. Landing all four produces v0.3.7. + +**Tech Stack:** Swift 6 (macOS + SPM), POSIX `dup2`/pipes for stdout capture, HF REST API for SHA comparison, GitHub Actions. + +**Branch:** `feat/v0.3.7-maintenance` (already created). + +--- + +## File Structure + +Files created: +- `macMLX/macMLX/App/StdoutCapture.swift` — launch-time fd redirection that tees stdout/stderr into LogManager +- `MacMLXCore/Sources/MacMLXCore/Models/DownloadedModelMeta.swift` — `.macmlx-meta.json` sidecar schema +- `MacMLXCore/Tests/MacMLXCoreTests/Models/DownloadedModelMetaTests.swift` — roundtrip tests + +Files modified: +- `.github/workflows/ci.yml`, `.github/workflows/release.yml` — bump node-based actions +- `macMLX/macMLX/App/AppState.swift` — write GUI PIDFile on server start, clear on stop +- `macmlx-cli/Sources/macmlx/Commands/ServeCommand.swift` — refuse start when GUI server is up +- `macmlx-cli/Sources/macmlx/Commands/PSCommand.swift` — distinguish GUI vs CLI owner in output +- `MacMLXCore/Sources/MacMLXCore/Managers/HFDownloader.swift` — write sidecar on download finish; add `updateStatus(for:local:)` helper +- `macMLX/macMLX/Views/ModelLibrary/ModelLibraryViewModel.swift` — periodic update check, expose `modelsWithUpdate` set +- `macMLX/macMLX/Views/ModelLibrary/LocalModelRow.swift` — "Update available" badge + action button + +--- + +## Task 1: Upgrade GitHub Actions to Node.js 24 + +**Files:** +- Modify: `.github/workflows/ci.yml` +- Modify: `.github/workflows/release.yml` + +Current `actions/checkout@v4` and `actions/cache@v4` still work but are pinned to Node 20. Each action has a newer major that targets Node 24: +- `actions/checkout@v5` — Node 24 +- `actions/cache@v5` — Node 24 + +- [ ] **Step 1: Update ci.yml** + +Find all three occurrences (two `checkout`, two `cache`) and bump. Diff: + +```yaml +- - uses: actions/checkout@v4 ++ - uses: actions/checkout@v5 +``` + +```yaml +- uses: actions/cache@v4 ++ uses: actions/cache@v5 +``` + +- [ ] **Step 2: Update release.yml** + +```yaml +- - uses: actions/checkout@v4 ++ - uses: actions/checkout@v5 +``` + +Check if any other `@v4` actions exist in release.yml (cache, upload-artifact, etc.) and bump similarly. + +- [ ] **Step 3: Commit + push branch to trigger CI** + +```bash +git add .github/workflows/ci.yml .github/workflows/release.yml +git commit -m "chore(ci): upgrade GitHub Actions to Node.js 24 (actions/{checkout,cache}@v5)" +git push -u origin feat/v0.3.7-maintenance +``` + +- [ ] **Step 4: Verify CI green** + +Watch the push trigger: + +```bash +gh run watch $(gh run list --branch feat/v0.3.7-maintenance --limit 1 --json databaseId -q '.[0].databaseId') --exit-status +``` + +Expected: SPM Build & Test + Xcode App Build both succeed. ANNOTATIONS no longer warns about Node.js 20. + +--- + +## Task 2: MLX stdout/stderr capture → Logs tab + +**Files:** +- Create: `macMLX/macMLX/App/StdoutCapture.swift` +- Modify: `macMLX/macMLX/macMLXApp.swift` — call `StdoutCapture.install()` at launch + +**Background:** `mlx-swift-lm` and its deps print to stdout/stderr during model load and generation (progress bars, warnings, allocator messages). These bypass `LogManager` and are invisible in the GUI Logs tab. + +- [ ] **Step 1: Create StdoutCapture.swift** + +```swift +// StdoutCapture.swift +// macMLX +// +// Dup stdout + stderr to a pipe at launch, read the pipe on a background +// task, and tee every line into both the original fd (so terminal output +// still works when launched from CLI) and LogManager.shared. + +import Darwin +import Foundation +import MacMLXCore + +enum StdoutCapture { + /// True once installed so repeat calls (e.g. from SwiftUI previews) + /// are no-ops instead of double-redirecting. + private static var installed = false + + /// Redirect STDOUT_FILENO and STDERR_FILENO to a Pipe. Launch a + /// background task that reads the pipe and forwards each line to: + /// - the original fd (preserving terminal visibility) + /// - LogManager.shared at .debug on category .system + /// Call exactly once from App.init(). + static func install() { + guard !installed else { return } + installed = true + + redirect(fd: STDOUT_FILENO, label: "stdout") + redirect(fd: STDERR_FILENO, label: "stderr") + } + + private static func redirect(fd: Int32, label: String) { + // Save original for tee. + let originalFD = dup(fd) + guard originalFD >= 0 else { return } + + // Create a pipe and point the source fd at its write side. + let pipe = Pipe() + let writeFD = pipe.fileHandleForWriting.fileDescriptor + guard dup2(writeFD, fd) >= 0 else { return } + + // Pipe's write fd is now duplicated to the original fd; we can + // close the pipe's side to avoid double-ownership. Apple's + // Pipe retains its own end so the fd stays live. + _ = close(writeFD) + + // Read loop: buffer bytes until \n, forward each line. Runs on + // a detached Task so it never blocks app lifecycle. + let reader = pipe.fileHandleForReading + Task.detached(priority: .utility) { + var buffer = Data() + while true { + let chunk = reader.availableData + if chunk.isEmpty { + // EOF — very unusual for stdout; bail. + return + } + // Mirror to original fd so terminal still sees it. + chunk.withUnsafeBytes { ptr in + _ = write(originalFD, ptr.baseAddress, chunk.count) + } + buffer.append(chunk) + while let newlineIdx = buffer.firstIndex(of: 0x0A) { + let lineData = buffer[buffer.startIndex..&1 | tail -3 +``` + +Expected: `BUILD SUCCEEDED`. + +Run the app, load a model, then open Logs tab and filter by `system` + include `.debug`. Expected: lines prefixed with `[stdout]` or `[stderr]` containing MLX progress / tokenization messages. + +- [ ] **Step 4: Commit** + +```bash +git add macMLX/macMLX/App/StdoutCapture.swift macMLX/macMLX/macMLXApp.swift +git commit -m "feat(logs): capture MLX stdout/stderr into Logs tab + +dup2 STDOUT_FILENO and STDERR_FILENO to a Pipe at launch, tee each +line to both the original fd (terminal visibility preserved) and +LogManager.shared.debug(category:.system). Runs on a detached +background Task, never blocks app lifecycle. + +Users who report 'model is slow' can now see what MLX is actually +doing without attaching a debugger." +``` + +--- + +## Task 3: HF model-update detection + +**Files:** +- Create: `MacMLXCore/Sources/MacMLXCore/Models/DownloadedModelMeta.swift` +- Create: `MacMLXCore/Tests/MacMLXCoreTests/Models/DownloadedModelMetaTests.swift` +- Modify: `MacMLXCore/Sources/MacMLXCore/Managers/HFDownloader.swift` — write sidecar post-download; add update-check helper +- Modify: `macMLX/macMLX/Views/ModelLibrary/ModelLibraryViewModel.swift` — throttled update check +- Modify: `macMLX/macMLX/Views/ModelLibrary/LocalModelRow.swift` — "Update available" badge + +### Step 1: Define sidecar file + +File: `MacMLXCore/Sources/MacMLXCore/Models/DownloadedModelMeta.swift` + +```swift +import Foundation + +/// Metadata sidecar stored next to a downloaded model at +/// `/.macmlx-meta.json`. Persisted on first successful +/// download so we can later detect when the Hub repo has advanced +/// past this snapshot. +public struct DownloadedModelMeta: Codable, Sendable { + /// Full HF model ID (e.g. `mlx-community/Qwen3-8B-4bit`). + public let modelID: String + /// Commit SHA of the `main` branch at download time, if HF + /// exposed it. Nil for older downloads predating this field. + public let commitSHA: String? + /// `lastModified` timestamp reported by `/api/models/{id}` at + /// download time. + public let lastModifiedAtDownload: Date? + /// Wall-clock time of the download event (may lag behind + /// `lastModifiedAtDownload` by minutes). + public let downloadedAt: Date + + public init( + modelID: String, + commitSHA: String?, + lastModifiedAtDownload: Date?, + downloadedAt: Date = Date() + ) { + self.modelID = modelID + self.commitSHA = commitSHA + self.lastModifiedAtDownload = lastModifiedAtDownload + self.downloadedAt = downloadedAt + } + + /// Canonical sidecar filename used inside the model directory. + public static let filename = ".macmlx-meta.json" + + /// Path for a given model directory. + public static func url(inside modelDir: URL) -> URL { + modelDir.appending(path: filename, directoryHint: .notDirectory) + } + + /// Read from `/.macmlx-meta.json`. Returns `nil` when the + /// file is absent (pre-v0.3.7 downloads) or unreadable/corrupt. + public static func load(from modelDir: URL) -> DownloadedModelMeta? { + let fileURL = url(inside: modelDir) + guard let data = try? Data(contentsOf: fileURL) else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode(DownloadedModelMeta.self, from: data) + } + + /// Write atomically to `/.macmlx-meta.json`. + public func save(to modelDir: URL) throws { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted] + let data = try encoder.encode(self) + try data.write(to: Self.url(inside: modelDir), options: .atomic) + } +} +``` + +- [ ] **Write tests** + +File: `MacMLXCore/Tests/MacMLXCoreTests/Models/DownloadedModelMetaTests.swift` + +```swift +import XCTest +@testable import MacMLXCore + +final class DownloadedModelMetaTests: XCTestCase { + + private func tmpDir() -> URL { + let dir = FileManager.default.temporaryDirectory + .appending(path: "macmlx-meta-test-\(UUID().uuidString)", directoryHint: .isDirectory) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + func testRoundtrip() throws { + let dir = tmpDir() + let meta = DownloadedModelMeta( + modelID: "mlx-community/Qwen3-8B-4bit", + commitSHA: "abc123", + lastModifiedAtDownload: Date(timeIntervalSince1970: 1_700_000_000) + ) + try meta.save(to: dir) + let loaded = DownloadedModelMeta.load(from: dir) + XCTAssertEqual(loaded?.modelID, "mlx-community/Qwen3-8B-4bit") + XCTAssertEqual(loaded?.commitSHA, "abc123") + XCTAssertEqual( + loaded?.lastModifiedAtDownload?.timeIntervalSince1970 ?? 0, + 1_700_000_000, + accuracy: 1 + ) + } + + func testMissingSidecar() { + let dir = tmpDir() + XCTAssertNil(DownloadedModelMeta.load(from: dir)) + } + + func testCorruptSidecar() throws { + let dir = tmpDir() + try "not json".data(using: .utf8)!.write( + to: DownloadedModelMeta.url(inside: dir), + options: .atomic + ) + XCTAssertNil(DownloadedModelMeta.load(from: dir)) + } +} +``` + +Run: `cd MacMLXCore && swift test --filter DownloadedModelMetaTests` +Expected: 3/3 PASS. + +### Step 2: Write sidecar at download finish + +File: `MacMLXCore/Sources/MacMLXCore/Managers/HFDownloader.swift` + +Add HF detail-endpoint fetching of commit SHA + lastModified via an extension to the existing `/api/models/{id}` path. The existing `ModelDetailsEnvelope` private struct already decodes `siblings`; extend it: + +```swift +private struct ModelDetailsEnvelope: Decodable { + let siblings: [SiblingEntry] + let sha: String? // ← new: HF commit SHA (main) + let lastModified: Date? // ← new: repo lastModified + + struct SiblingEntry: Decodable { + let rfilename: String + let size: Int64? + } +} +``` + +Then in `files(for:)`, capture sha + lastModified into a private cache so `download(...)` can write the sidecar without a second round-trip. Simplest: a new method `downloadMeta(for modelID:)` returning `(files: [HFRemoteFile], sha: String?, lastModified: Date?)`; have `download(...)` call this first and write the sidecar after successful completion: + +```swift +/// Fetch files + commit metadata in a single Hub request. +public func downloadMeta(for modelID: String) async throws -> ( + files: [HFRemoteFile], + sha: String?, + lastModified: Date? +) { + let url = baseURL.appending(path: "api/models/\(modelID)") + let data = try await fetchData(from: url) + let envelope = try JSONDecoder.huggingFace.decode(ModelDetailsEnvelope.self, from: data) + let files = envelope.siblings.map { HFRemoteFile(path: $0.rfilename, size: $0.size, lfs: false) } + return (files, envelope.sha, envelope.lastModified) +} +``` + +In `download(...)`: + +1. Replace `let remoteFiles = try await files(for: modelID)` with a call to `downloadMeta(for:)` and capture `(files, sha, lastModified)`. +2. After the file-loop success (right before `clearResumeRecord(for: modelID); return modelDir`), persist the sidecar: + +```swift +let meta = DownloadedModelMeta( + modelID: modelID, + commitSHA: sha, + lastModifiedAtDownload: lastModified +) +try? meta.save(to: modelDir) +``` + +- [ ] **Build + test** + +``` +cd MacMLXCore && swift build 2>&1 | tail -5 +cd MacMLXCore && swift test 2>&1 | tail -5 +``` + +Expected: no regressions. Existing `HFDownloaderTests` mocks the model-details response — the new `sha`/`lastModified` fields are optional, decoding tolerates absence. + +### Step 3: Add `updateStatus` helper to HFDownloader + +```swift +/// Snapshot of how a local download compares to the Hub's current +/// head. `.upToDate` when sha matches or the sidecar lacks a sha +/// but lastModified hasn't advanced; `.updateAvailable` when sha +/// differs OR lastModified is newer; `.unknown` if the sidecar is +/// missing or the Hub call fails. +public enum UpdateStatus: Sendable, Equatable { + case upToDate + case updateAvailable(commitSHA: String?, lastModified: Date?) + case unknown +} + +public func updateStatus(for meta: DownloadedModelMeta) async -> UpdateStatus { + do { + let url = baseURL.appending(path: "api/models/\(meta.modelID)") + let data = try await fetchData(from: url) + let envelope = try JSONDecoder.huggingFace.decode( + ModelDetailsEnvelope.self, from: data + ) + // SHA comparison is strongest. Fall back to lastModified. + if let localSHA = meta.commitSHA, let remoteSHA = envelope.sha { + return localSHA == remoteSHA + ? .upToDate + : .updateAvailable(commitSHA: remoteSHA, lastModified: envelope.lastModified) + } + if let localTime = meta.lastModifiedAtDownload, let remoteTime = envelope.lastModified { + return remoteTime > localTime + ? .updateAvailable(commitSHA: envelope.sha, lastModified: remoteTime) + : .upToDate + } + return .unknown + } catch { + return .unknown + } +} +``` + +### Step 4: Hook into ModelLibraryViewModel + +File: `macMLX/macMLX/Views/ModelLibrary/ModelLibraryViewModel.swift` + +Add: + +```swift +/// Model IDs for which an update is available on HF. Populated by +/// `checkForModelUpdates()` — throttled to fire at most once per +/// `updateCheckInterval` to avoid spamming the Hub. +var modelsWithUpdate: Set = [] + +private var lastUpdateCheck: Date? +private let updateCheckInterval: TimeInterval = 24 * 60 * 60 // 1 day + +/// Trigger in the background if it's been more than a day. No-op +/// otherwise. Called from `loadLocalModels()` once scan completes. +func checkForModelUpdates() { + if let last = lastUpdateCheck, + Date().timeIntervalSince(last) < updateCheckInterval { + return + } + lastUpdateCheck = Date() + let snapshot = localModels + let downloader = self.downloader + Task { [weak self] in + var withUpdate = Set() + await withTaskGroup(of: (String, Bool).self) { group in + for model in snapshot { + guard let meta = DownloadedModelMeta.load(from: model.directory) else { continue } + group.addTask { + let status = await downloader.updateStatus(for: meta) + if case .updateAvailable = status { + return (model.id, true) + } + return (model.id, false) + } + } + while let (id, hasUpdate) = await group.next() { + if hasUpdate { withUpdate.insert(id) } + } + } + await MainActor.run { + self?.modelsWithUpdate = withUpdate + } + } +} +``` + +Call `checkForModelUpdates()` at the end of `loadLocalModels()`: + +```swift +func loadLocalModels() async { + // ... existing body ... + checkForModelUpdates() +} +``` + +### Step 5: Render badge in LocalModelRow + +File: `macMLX/macMLX/Views/ModelLibrary/LocalModelRow.swift` + +Add a new parameter: + +```swift +let hasUpdateAvailable: Bool +``` + +Then in the row's HStack (next to the displayName or size badge), when `hasUpdateAvailable`: + +```swift +if hasUpdateAvailable { + Label("Update available", systemImage: "arrow.triangle.2.circlepath.circle.fill") + .font(.caption) + .foregroundStyle(.orange) + .labelStyle(.titleAndIcon) +} +``` + +Wire up from `ModelLibraryView.localTab`: + +```swift +LocalModelRow( + model: model, + isLoaded: viewModel.loadedModelID == model.id, + isLoading: viewModel.loadingModelID == model.id, + hasUpdateAvailable: viewModel.modelsWithUpdate.contains(model.id), + onLoad: { ... }, + onUnload: { ... }, + onDelete: { ... } +) +``` + +- [ ] **Manual verify** + +Download a model that's been updated on the Hub since our download → badge appears on next Models-tab load (or after 24h). No user test for the timestamp logic without tampering with system clock; rely on the unit tests for roundtrip correctness. + +- [ ] **Commit** (single commit for the whole task — cohesive) + +```bash +git add MacMLXCore/Sources/MacMLXCore/Models/DownloadedModelMeta.swift \ + MacMLXCore/Tests/MacMLXCoreTests/Models/DownloadedModelMetaTests.swift \ + MacMLXCore/Sources/MacMLXCore/Managers/HFDownloader.swift \ + macMLX/macMLX/Views/ModelLibrary/ModelLibraryViewModel.swift \ + macMLX/macMLX/Views/ModelLibrary/ModelLibraryView.swift \ + macMLX/macMLX/Views/ModelLibrary/LocalModelRow.swift +git commit -m "feat(library): detect when a downloaded model has an update on HF + +Write `.macmlx-meta.json` sidecar to every freshly-downloaded model +directory capturing HF commit SHA + lastModified. On Models-tab +open, throttle-check each sidecar against the Hub (bounded to once +a day per model). Rows whose Hub head has advanced get an orange +'Update available' badge. Delete-and-re-download is the update +action for now; a one-click refresh is v0.4+." +``` + +--- + +## Task 4: GUI↔CLI state convergence + +**Minimum viable version**: GUI writes the same PIDFile CLI already uses; CLI commands detect a GUI-owned server and refuse to double-bind. + +**Files:** +- Modify: `macMLX/macMLX/App/AppState.swift` — `startServer()` writes PIDFile after bind; `stopServer()` clears it +- Modify: `macmlx-cli/Sources/macmlx/Commands/ServeCommand.swift` — refuse start when a live PID file exists +- Modify: `macmlx-cli/Sources/macmlx/Commands/PSCommand.swift` — label "GUI" vs "CLI" in output + +Note: `macmlx-cli/Sources/macmlx/Shared/PIDFile.swift` already exists with `write(_ record:)` / `read()` / `clear()`. Extend the `Record` struct with an `owner` field: + +### Step 1: Extend PIDFile.Record + +File: `macmlx-cli/Sources/macmlx/Shared/PIDFile.swift` + +PIDFile is in the CLI target, so not visible to the GUI target. Move it to MacMLXCore: + +- [ ] Create `MacMLXCore/Sources/MacMLXCore/Managers/PIDFile.swift` — same contents as the CLI copy, with `owner` field added: + +```swift +public struct Record: Codable, Sendable { + public enum Owner: String, Codable, Sendable { + case gui + case cli + } + public var pid: Int32 + public var port: Int + public var modelID: String? + public var startedAt: Date + /// Which binary wrote this record — used by `macmlx ps` to label + /// the process and by `macmlx serve` to refuse starting if the + /// GUI is already serving on the same port. + public var owner: Owner + + public init( + pid: Int32, + port: Int, + modelID: String?, + startedAt: Date, + owner: Owner + ) { + self.pid = pid + self.port = port + self.modelID = modelID + self.startedAt = startedAt + self.owner = owner + } +} +``` + +Decode with backward compat: older PID files lack `owner` — default to `.cli` on missing: + +```swift +// Custom init(from:) in Record +public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.pid = try c.decode(Int32.self, forKey: .pid) + self.port = try c.decode(Int.self, forKey: .port) + self.modelID = try c.decodeIfPresent(String.self, forKey: .modelID) + self.startedAt = try c.decode(Date.self, forKey: .startedAt) + self.owner = (try c.decodeIfPresent(Owner.self, forKey: .owner)) ?? .cli +} +``` + +- [ ] Delete the CLI's `macmlx-cli/Sources/macmlx/Shared/PIDFile.swift`, replace with `@_exported import` (if package allows) or a direct type-alias to `MacMLXCore.PIDFile`. Simpler: just delete and update every reference (`ServeCommand.swift`, `PSCommand.swift`, `StopCommand.swift`) to `import MacMLXCore` and use `PIDFile` from there. + +### Step 2: GUI writes PIDFile + +File: `macMLX/macMLX/App/AppState.swift` + +In `startServer()`, immediately after `serverPort = actualPort`: + +```swift +let record = PIDFile.Record( + pid: Int32(ProcessInfo.processInfo.processIdentifier), + port: actualPort, + modelID: coordinator.currentModel?.id, + startedAt: Date(), + owner: .gui +) +try? PIDFile.write(record) +``` + +In `stopServer()`, after `server = nil`: + +```swift +try? PIDFile.clear() +``` + +### Step 3: CLI `serve` refuses double-bind + +File: `macmlx-cli/Sources/macmlx/Commands/ServeCommand.swift` + +Near the top of `run()`, before any server setup: + +```swift +if let existing = try? PIDFile.read(), + kill(existing.pid, 0) == 0 { + // Process is alive. Refuse to start. + let ownerLabel = existing.owner == .gui ? "GUI" : "CLI" + let msg = """ + Another macMLX server is already running on :\(existing.port) (\(ownerLabel), PID \(existing.pid)). + Close the \(ownerLabel) or run `macmlx stop` first. + """ + throw ValidationError(msg) +} +``` + +The `kill(pid, 0)` POSIX idiom checks whether the PID is alive without signalling. + +### Step 4: CLI `ps` labels owner + +File: `macmlx-cli/Sources/macmlx/Commands/PSCommand.swift` + +Find the print that lists the running record. Add an `Owner:` line: + +```swift +print(" Owner: \(record.owner.rawValue.uppercased())") +``` + +### Step 5: Build + test + +``` +cd MacMLXCore && swift build 2>&1 | tail -5 +cd MacMLXCore && swift test 2>&1 | tail -5 +cd macmlx-cli && swift build 2>&1 | tail -5 +cd macMLX && xcodebuild -scheme macMLX -destination 'platform=macOS' -configuration Debug build 2>&1 | tail -5 +``` + +All must succeed. + +Manual verify: + +1. Launch GUI, toggle auto-start server in Settings → PIDFile at `~/.mac-mlx/macmlx.pid` should show `"owner": "gui"`. +2. In another terminal: `macmlx ps` should show `Owner: GUI`. +3. `macmlx serve` should error with `Another macMLX server is already running on :8000 (GUI, PID …)`. +4. Close GUI → GUI stopServer clears the file → `macmlx ps` returns "not running" → `macmlx serve` succeeds. + +### Step 6: Commit + +```bash +git add MacMLXCore/Sources/MacMLXCore/Managers/PIDFile.swift \ + macmlx-cli/Sources/macmlx/Commands/ServeCommand.swift \ + macmlx-cli/Sources/macmlx/Commands/PSCommand.swift \ + macmlx-cli/Sources/macmlx/Commands/StopCommand.swift \ + macMLX/macMLX/App/AppState.swift +# Also delete the old CLI copy: +git rm macmlx-cli/Sources/macmlx/Shared/PIDFile.swift +git commit -m "feat(daemon): GUI and CLI share ~/.mac-mlx/macmlx.pid + +Moves PIDFile from macmlx-cli to MacMLXCore so both targets can +read/write it. Record now carries an Owner enum (.gui | .cli) so: + +- GUI's startServer() writes a record tagged .gui on bind. +- CLI's macmlx serve checks for a live PID (kill(pid, 0)) and + refuses to start if one exists, naming the owner so the user + knows where to look. +- macmlx ps shows the Owner label. +- GUI's stopServer() clears the file. + +Backward compat: decodes pre-v0.3.7 PID files (no owner key) as +.cli. Full Ollama-style daemon mode (CLI requests proxied through +GUI HTTP) remains a follow-up for v0.3.8 or later." +``` + +--- + +## Self-Review + +- ✅ **Spec coverage:** All four v0.3.7 items from the roadmap have tasks: Task 1 (Node.js 24), Task 2 (stdout capture), Task 3 (update detection), Task 4 (GUI↔CLI). +- ✅ **Placeholder scan:** no TBD / TODO / "add appropriate" — every step has exact code or exact command. +- ✅ **Type consistency:** `DownloadedModelMeta`, `UpdateStatus`, `PIDFile.Record.Owner` referenced consistently across tasks. `owner` field name stable. +- ⚠️ **Known gap:** Task 4's "CLI requests proxied through GUI HTTP" (full Ollama daemon mode) is intentionally deferred — the MVP here just prevents double-binding. Full proxy is a v0.3.8 scope bump. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-04-18-v0.3.7-maintenance.md`. Two execution options: + +1. **Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks, fast iteration. +2. **Inline Execution** — execute tasks in this session using `superpowers:executing-plans`. + +Recommended order: Task 1 (tiny, unblocks CI for subsequent pushes) → Task 4 (moves PIDFile, foundation for any later daemon work) → Task 2 (stdout capture) → Task 3 (update detection). + +Which approach? From 9e90f9b069d71a4f30894c5434a33aefe948232b Mon Sep 17 00:00:00 2001 From: Kefeng Zhou Date: Sat, 18 Apr 2026 20:32:48 +0700 Subject: [PATCH 2/7] chore(ci): upgrade GitHub Actions to Node.js 24 (checkout@v5, cache@v5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silences the 'Node.js 20 deprecated' runner warning that surfaced during v0.3.6 release. Both major bumps are drop-in replacements — same inputs, same outputs, only the runtime changes. --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/release.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4872dc..c88f1eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,13 +43,13 @@ jobs: runs-on: macos-15 timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Pin Xcode 16.4 run: sudo xcode-select -s /Applications/Xcode_16.4.app - name: Cache SPM checkouts + build - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | MacMLXCore/.build @@ -84,13 +84,13 @@ jobs: runs-on: macos-15 timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Pin Xcode 16.4 run: sudo xcode-select -s /Applications/Xcode_16.4.app - name: Cache Xcode DerivedData - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/Library/Developer/Xcode/DerivedData diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 547f6a5..e55dac2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} From 6de46804a1fc45b2b5f3e534d5cf7bdccd960007 Mon Sep 17 00:00:00 2001 From: Kefeng Zhou Date: Sat, 18 Apr 2026 20:39:02 +0700 Subject: [PATCH 3/7] feat(logs): capture MLX stdout/stderr into the Logs tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dup2 STDOUT_FILENO and STDERR_FILENO to a Pipe at launch, tee each line to both the original fd (terminal visibility preserved) and LogManager.shared.debug(category:.system). Runs on a detached background Task, never blocks app lifecycle. Users reporting "model is slow" or "got a cryptic crash" can now see what mlx-swift-lm is printing without attaching a debugger. Install point is macMLXApp.init() BEFORE AppState is constructed — the engine's first load triggers the first MLX prints, so the redirect must be in place by then. @State(initialValue:) is the idiomatic way to defer AppState construction while keeping it as a @State var. --- macMLX/macMLX/App/StdoutCapture.swift | 76 +++++++++++++++++++++++++++ macMLX/macMLX/macMLXApp.swift | 10 +++- 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 macMLX/macMLX/App/StdoutCapture.swift diff --git a/macMLX/macMLX/App/StdoutCapture.swift b/macMLX/macMLX/App/StdoutCapture.swift new file mode 100644 index 0000000..8b75d49 --- /dev/null +++ b/macMLX/macMLX/App/StdoutCapture.swift @@ -0,0 +1,76 @@ +// StdoutCapture.swift +// macMLX +// +// Dup stdout + stderr to a pipe at launch, read the pipe on a background +// task, and tee every line into both the original fd (so terminal output +// still works when launched from CLI) and LogManager. + +import Darwin +import Foundation +import MacMLXCore + +enum StdoutCapture { + /// True once installed so repeat calls (e.g. from SwiftUI previews) + /// are no-ops instead of double-redirecting. + private static var installed = false + + /// Redirect STDOUT_FILENO and STDERR_FILENO to a Pipe. Launch a + /// background task that reads the pipe and forwards each line to: + /// - the original fd (preserving terminal visibility) + /// - LogManager.shared at .debug on category .system + /// Call exactly once from App.init(). + static func install() { + guard !installed else { return } + installed = true + + redirect(fd: STDOUT_FILENO, label: "stdout") + redirect(fd: STDERR_FILENO, label: "stderr") + } + + private static func redirect(fd: Int32, label: String) { + // Save the original fd so we can still write to it (terminal tee). + let originalFD = dup(fd) + guard originalFD >= 0 else { return } + + // Create a pipe and point the source fd at its write side. + let pipe = Pipe() + let writeFD = pipe.fileHandleForWriting.fileDescriptor + guard dup2(writeFD, fd) >= 0 else { return } + + // Pipe's write fd is now duplicated to the original fd; close the + // pipe's side to avoid double-ownership. Foundation's Pipe retains + // its own reference so the fd stays live. + _ = close(writeFD) + + // Read loop: buffer bytes until \n, forward each line. Runs on a + // detached Task so it never blocks app lifecycle. + let reader = pipe.fileHandleForReading + Task.detached(priority: .utility) { + var buffer = Data() + while true { + let chunk = reader.availableData + if chunk.isEmpty { + // EOF — very unusual for stdout; bail so the task + // doesn't spin. + return + } + // Mirror to original fd so terminal still sees it. + chunk.withUnsafeBytes { ptr in + _ = write(originalFD, ptr.baseAddress, chunk.count) + } + buffer.append(chunk) + while let newlineIdx = buffer.firstIndex(of: 0x0A) { + let lineData = buffer[buffer.startIndex.. Date: Sat, 18 Apr 2026 20:43:37 +0700 Subject: [PATCH 4/7] feat(library): detect when a downloaded model has an update on HF Write `.macmlx-meta.json` sidecar to every freshly-downloaded model directory capturing HF commit SHA + lastModified at download time. On Models-tab open, throttle-check each sidecar against the Hub (bounded to once a day). Rows whose head has advanced get an orange 'Update available' badge. Delete-and-re-download is the update action for now; a one-click refresh is a v0.4 follow-up. New unit tests: DownloadedModelMetaTests (3 cases) covering roundtrip, missing sidecar, corrupt sidecar. --- .../MacMLXCore/Managers/HFDownloader.swift | 62 ++++++++++++++++++- .../Models/DownloadedModelMeta.swift | 53 ++++++++++++++++ .../Models/DownloadedModelMetaTests.swift | 42 +++++++++++++ .../Views/ModelLibrary/LocalModelRow.swift | 10 +++ .../Views/ModelLibrary/ModelLibraryView.swift | 1 + .../ModelLibrary/ModelLibraryViewModel.swift | 42 +++++++++++++ 6 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 MacMLXCore/Sources/MacMLXCore/Models/DownloadedModelMeta.swift create mode 100644 MacMLXCore/Tests/MacMLXCoreTests/Models/DownloadedModelMetaTests.swift diff --git a/MacMLXCore/Sources/MacMLXCore/Managers/HFDownloader.swift b/MacMLXCore/Sources/MacMLXCore/Managers/HFDownloader.swift index 23278db..5930cba 100644 --- a/MacMLXCore/Sources/MacMLXCore/Managers/HFDownloader.swift +++ b/MacMLXCore/Sources/MacMLXCore/Managers/HFDownloader.swift @@ -344,6 +344,52 @@ public actor HFDownloader { } } + /// Fetch files + commit metadata in a single Hub request. + /// Used by `download(...)` to write the `.macmlx-meta.json` + /// sidecar without a second round-trip. + public func downloadMeta(for modelID: String) async throws -> ( + files: [HFRemoteFile], + sha: String?, + lastModified: Date? + ) { + let url = baseURL.appending(path: "api/models/\(modelID)") + let data = try await fetchData(from: url) + let envelope = try JSONDecoder.huggingFace.decode(ModelDetailsEnvelope.self, from: data) + let files = envelope.siblings.map { HFRemoteFile(path: $0.rfilename, size: $0.size, lfs: false) } + return (files, envelope.sha, envelope.lastModified) + } + + /// Snapshot of how a local download compares to the Hub's current + /// head. + public enum UpdateStatus: Sendable, Equatable { + case upToDate + case updateAvailable(commitSHA: String?, lastModified: Date?) + case unknown + } + + public func updateStatus(for meta: DownloadedModelMeta) async -> UpdateStatus { + do { + let url = baseURL.appending(path: "api/models/\(meta.modelID)") + let data = try await fetchData(from: url) + let envelope = try JSONDecoder.huggingFace.decode( + ModelDetailsEnvelope.self, from: data + ) + if let localSHA = meta.commitSHA, let remoteSHA = envelope.sha { + return localSHA == remoteSHA + ? .upToDate + : .updateAvailable(commitSHA: remoteSHA, lastModified: envelope.lastModified) + } + if let localTime = meta.lastModifiedAtDownload, let remoteTime = envelope.lastModified { + return remoteTime > localTime + ? .updateAvailable(commitSHA: envelope.sha, lastModified: remoteTime) + : .upToDate + } + return .unknown + } catch { + return .unknown + } + } + /// Total size of all files in the model repo, in bytes. /// /// HF's `/api/models/{id}` endpoint omits `size` for LFS-backed @@ -461,7 +507,7 @@ public actor HFDownloader { try FileManager.default.createDirectory(at: modelDir, withIntermediateDirectories: true) - let remoteFiles = try await files(for: modelID) + let (remoteFiles, remoteSHA, remoteLastModified) = try await downloadMeta(for: modelID) let fileCount = remoteFiles.count guard fileCount > 0 else { return modelDir } @@ -609,6 +655,18 @@ public actor HFDownloader { )) } + // Persist the sidecar so a later `updateStatus(for:)` call can + // compare the Hub's head against what we snapshotted at download + // time. Best-effort: a write failure shouldn't fail the download + // itself — the user just won't get an "Update available" badge + // for this model until they re-download. + let meta = DownloadedModelMeta( + modelID: modelID, + commitSHA: remoteSHA, + lastModifiedAtDownload: remoteLastModified + ) + try? meta.save(to: modelDir) + // All files complete — clear any leftover record (covers the edge // case where user cancelled file 2, then later resumed and // completed everything including a pending record on file 2). @@ -968,6 +1026,8 @@ internal final class DownloadSessionRouter: NSObject, private struct ModelDetailsEnvelope: Decodable { let siblings: [SiblingEntry] + let sha: String? + let lastModified: Date? struct SiblingEntry: Decodable { let rfilename: String diff --git a/MacMLXCore/Sources/MacMLXCore/Models/DownloadedModelMeta.swift b/MacMLXCore/Sources/MacMLXCore/Models/DownloadedModelMeta.swift new file mode 100644 index 0000000..756215b --- /dev/null +++ b/MacMLXCore/Sources/MacMLXCore/Models/DownloadedModelMeta.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Metadata sidecar stored next to a downloaded model at +/// `/.macmlx-meta.json`. Persisted on first successful +/// download so we can later detect when the Hub repo has advanced +/// past this snapshot. +public struct DownloadedModelMeta: Codable, Sendable { + /// Full HF model ID (e.g. `mlx-community/Qwen3-8B-4bit`). + public let modelID: String + /// Commit SHA of the `main` branch at download time, if HF + /// exposed it. Nil for older downloads predating this field. + public let commitSHA: String? + /// `lastModified` timestamp reported by `/api/models/{id}` at + /// download time. + public let lastModifiedAtDownload: Date? + /// Wall-clock time of the download event (may lag behind + /// `lastModifiedAtDownload` by minutes). + public let downloadedAt: Date + + public init( + modelID: String, + commitSHA: String?, + lastModifiedAtDownload: Date?, + downloadedAt: Date = Date() + ) { + self.modelID = modelID + self.commitSHA = commitSHA + self.lastModifiedAtDownload = lastModifiedAtDownload + self.downloadedAt = downloadedAt + } + + public static let filename = ".macmlx-meta.json" + + public static func url(inside modelDir: URL) -> URL { + modelDir.appending(path: filename, directoryHint: .notDirectory) + } + + public static func load(from modelDir: URL) -> DownloadedModelMeta? { + let fileURL = url(inside: modelDir) + guard let data = try? Data(contentsOf: fileURL) else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode(DownloadedModelMeta.self, from: data) + } + + public func save(to modelDir: URL) throws { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted] + let data = try encoder.encode(self) + try data.write(to: Self.url(inside: modelDir), options: .atomic) + } +} diff --git a/MacMLXCore/Tests/MacMLXCoreTests/Models/DownloadedModelMetaTests.swift b/MacMLXCore/Tests/MacMLXCoreTests/Models/DownloadedModelMetaTests.swift new file mode 100644 index 0000000..3e7b2f1 --- /dev/null +++ b/MacMLXCore/Tests/MacMLXCoreTests/Models/DownloadedModelMetaTests.swift @@ -0,0 +1,42 @@ +import XCTest +@testable import MacMLXCore + +final class DownloadedModelMetaTests: XCTestCase { + private func tmpDir() -> URL { + let dir = FileManager.default.temporaryDirectory + .appending(path: "macmlx-meta-test-\(UUID().uuidString)", directoryHint: .isDirectory) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + func testRoundtrip() throws { + let dir = tmpDir() + let meta = DownloadedModelMeta( + modelID: "mlx-community/Qwen3-8B-4bit", + commitSHA: "abc123", + lastModifiedAtDownload: Date(timeIntervalSince1970: 1_700_000_000) + ) + try meta.save(to: dir) + let loaded = DownloadedModelMeta.load(from: dir) + XCTAssertEqual(loaded?.modelID, "mlx-community/Qwen3-8B-4bit") + XCTAssertEqual(loaded?.commitSHA, "abc123") + XCTAssertEqual( + loaded?.lastModifiedAtDownload?.timeIntervalSince1970 ?? 0, + 1_700_000_000, + accuracy: 1 + ) + } + + func testMissingSidecar() { + XCTAssertNil(DownloadedModelMeta.load(from: tmpDir())) + } + + func testCorruptSidecar() throws { + let dir = tmpDir() + try "not json".data(using: .utf8)!.write( + to: DownloadedModelMeta.url(inside: dir), + options: .atomic + ) + XCTAssertNil(DownloadedModelMeta.load(from: dir)) + } +} diff --git a/macMLX/macMLX/Views/ModelLibrary/LocalModelRow.swift b/macMLX/macMLX/Views/ModelLibrary/LocalModelRow.swift index c87f9f9..bf94d7a 100644 --- a/macMLX/macMLX/Views/ModelLibrary/LocalModelRow.swift +++ b/macMLX/macMLX/Views/ModelLibrary/LocalModelRow.swift @@ -9,6 +9,7 @@ struct LocalModelRow: View { let model: LocalModel let isLoaded: Bool let isLoading: Bool + let hasUpdateAvailable: Bool let onLoad: () -> Void let onUnload: () -> Void let onDelete: () -> Void @@ -47,6 +48,13 @@ struct LocalModelRow: View { .padding(.horizontal, 4) .padding(.vertical, 1) .background(Color.green.opacity(0.12), in: Capsule()) + + if hasUpdateAvailable { + Label("Update available", systemImage: "arrow.triangle.2.circlepath.circle.fill") + .font(.caption) + .foregroundStyle(.orange) + .labelStyle(.titleAndIcon) + } } } @@ -94,6 +102,7 @@ struct LocalModelRow: View { model: model, isLoaded: true, isLoading: false, + hasUpdateAvailable: false, onLoad: {}, onUnload: {}, onDelete: {} @@ -102,6 +111,7 @@ struct LocalModelRow: View { model: model, isLoaded: false, isLoading: false, + hasUpdateAvailable: true, onLoad: {}, onUnload: {}, onDelete: {} diff --git a/macMLX/macMLX/Views/ModelLibrary/ModelLibraryView.swift b/macMLX/macMLX/Views/ModelLibrary/ModelLibraryView.swift index 7b40748..0dd094a 100644 --- a/macMLX/macMLX/Views/ModelLibrary/ModelLibraryView.swift +++ b/macMLX/macMLX/Views/ModelLibrary/ModelLibraryView.swift @@ -160,6 +160,7 @@ private struct ModelLibraryContent: View { model: model, isLoaded: viewModel.loadedModelID == model.id, isLoading: viewModel.loadingModelID == model.id, + hasUpdateAvailable: viewModel.modelsWithUpdate.contains(model.id), onLoad: { Task { await viewModel.loadModel(model) } }, diff --git a/macMLX/macMLX/Views/ModelLibrary/ModelLibraryViewModel.swift b/macMLX/macMLX/Views/ModelLibrary/ModelLibraryViewModel.swift index b1a4e83..02082e1 100644 --- a/macMLX/macMLX/Views/ModelLibrary/ModelLibraryViewModel.swift +++ b/macMLX/macMLX/Views/ModelLibrary/ModelLibraryViewModel.swift @@ -45,6 +45,12 @@ final class ModelLibraryViewModel { /// (URLError.cancelled is thrown), wiring up issue #5's Cancel button. private var downloadTasks: [String: Task] = [:] + /// Model IDs for which an update is available on HF. + var modelsWithUpdate: Set = [] + + private var lastUpdateCheck: Date? + private let updateCheckInterval: TimeInterval = 24 * 60 * 60 // 1 day + // MARK: - Private // Explicit dependencies rather than a back-reference to AppState — @@ -124,6 +130,42 @@ final class ModelLibraryViewModel { localError = error.localizedDescription } isLoadingLocal = false + // Fire-and-forget HF update check — throttled to once a day + // internally so repeated tab visits don't hammer the Hub. + checkForModelUpdates() + } + + /// Fire in the background if it's been more than a day since the + /// last check. No-op otherwise. + func checkForModelUpdates() { + if let last = lastUpdateCheck, + Date().timeIntervalSince(last) < updateCheckInterval { + return + } + lastUpdateCheck = Date() + let snapshot = localModels + let downloader = self.downloader + Task { [weak self] in + var withUpdate = Set() + await withTaskGroup(of: (String, Bool).self) { group in + for model in snapshot { + guard let meta = DownloadedModelMeta.load(from: model.directory) else { continue } + group.addTask { + let status = await downloader.updateStatus(for: meta) + if case .updateAvailable = status { + return (model.id, true) + } + return (model.id, false) + } + } + while let (id, hasUpdate) = await group.next() { + if hasUpdate { withUpdate.insert(id) } + } + } + await MainActor.run { + self?.modelsWithUpdate = withUpdate + } + } } func loadModel(_ model: LocalModel) async { From 12301a5d4b8c1946dd9320091f9e8b79895a522b Mon Sep 17 00:00:00 2001 From: Kefeng Zhou Date: Sat, 18 Apr 2026 20:49:02 +0700 Subject: [PATCH 5/7] feat(daemon): GUI and CLI share ~/.mac-mlx/macmlx.pid Moves PIDFile from macmlx-cli to MacMLXCore so both targets read and write the same file. Record gains an Owner enum (.gui | .cli): - GUI startServer() writes a record tagged .gui on bind; stopServer() clears it. - CLI's `macmlx serve` now checks for a live PID with kill(pid, 0) and refuses to start when one exists, naming the owner so the user knows which process to shut down. - `macmlx ps` shows "Owner: GUI" or "Owner: CLI". Backward compat: decodes pre-v0.3.7 PID files (no owner key) as .cli so upgrading in place doesn't require cleanup. Full Ollama-style daemon mode (CLI commands proxied through the GUI's HTTP API) remains a later scope. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MacMLXCore/Managers}/PIDFile.swift | 54 +++++++++++++++---- macMLX/macMLX/App/AppState.swift | 15 ++++++ .../Sources/macmlx/Commands/PSCommand.swift | 2 + .../macmlx/Commands/ServeCommand.swift | 16 +++++- .../Sources/macmlx/Commands/StopCommand.swift | 1 + .../Tests/macmlxTests/PSCommandTests.swift | 7 ++- 6 files changed, 82 insertions(+), 13 deletions(-) rename {macmlx-cli/Sources/macmlx/Shared => MacMLXCore/Sources/MacMLXCore/Managers}/PIDFile.swift (50%) diff --git a/macmlx-cli/Sources/macmlx/Shared/PIDFile.swift b/MacMLXCore/Sources/MacMLXCore/Managers/PIDFile.swift similarity index 50% rename from macmlx-cli/Sources/macmlx/Shared/PIDFile.swift rename to MacMLXCore/Sources/MacMLXCore/Managers/PIDFile.swift index badc828..8535216 100644 --- a/macmlx-cli/Sources/macmlx/Shared/PIDFile.swift +++ b/MacMLXCore/Sources/MacMLXCore/Managers/PIDFile.swift @@ -1,33 +1,67 @@ import Foundation -/// Coordinates between `macmlx serve` and `macmlx stop`/`macmlx ps` via a -/// JSON file at `~/.mac-mlx/macmlx.pid`. +/// Coordinates server discovery between the GUI (`macMLX.app`) and +/// CLI (`macmlx`) — both write to `~/.mac-mlx/macmlx.pid` so either +/// side can tell whether the other is already serving on port 8000. /// -/// The PID file stores the process ID, port, optional loaded model ID, and -/// start time. It is written by `serve` on startup and removed on clean exit. +/// `Record.owner` distinguishes the two: GUI sets `.gui`, CLI sets +/// `.cli`. `macmlx ps` renders the owner; `macmlx serve` refuses to +/// start when a record whose PID is still alive is found. /// -/// NOTE: Race conditions between concurrent `serve` invocations are not -/// handled in v0.1 — the last writer wins. Document and skip. +/// Backward compat: pre-v0.3.7 PID files lacked the `owner` key and +/// were always written by the CLI. Decoding defaults missing `owner` +/// to `.cli` so upgrading in place doesn't require manually deleting +/// the pid file. public enum PIDFile { /// Persistent record stored in the PID file. public struct Record: Codable, Sendable { + /// Which process wrote this record — used by `macmlx serve` + /// to name the conflicting owner in its error message, and + /// by `macmlx ps` to show the user which side is serving. + public enum Owner: String, Codable, Sendable { + case gui + case cli + } + public var pid: Int32 public var port: Int public var modelID: String? public var startedAt: Date + public var owner: Owner - public init(pid: Int32, port: Int, modelID: String?, startedAt: Date) { + public init( + pid: Int32, + port: Int, + modelID: String?, + startedAt: Date, + owner: Owner + ) { self.pid = pid self.port = port self.modelID = modelID self.startedAt = startedAt + self.owner = owner + } + + private enum CodingKeys: String, CodingKey { + case pid, port, modelID, startedAt, owner + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.pid = try c.decode(Int32.self, forKey: .pid) + self.port = try c.decode(Int.self, forKey: .port) + self.modelID = try c.decodeIfPresent(String.self, forKey: .modelID) + self.startedAt = try c.decode(Date.self, forKey: .startedAt) + self.owner = (try c.decodeIfPresent(Owner.self, forKey: .owner)) ?? .cli } } - /// URL of the PID file: `~/.mac-mlx/macmlx.pid`. + /// URL of the PID file. Uses `DataRoot.macMLX` so the GUI (which + /// runs without sandbox since v0.3.6) and CLI both resolve to the + /// same real-home `~/.mac-mlx/` path. public static var url: URL { - FileManager.default.homeDirectoryForCurrentUser - .appending(path: ".mac-mlx/macmlx.pid") + DataRoot.macMLX.appending(path: "macmlx.pid", directoryHint: .notDirectory) } /// Write `record` to the PID file atomically. diff --git a/macMLX/macMLX/App/AppState.swift b/macMLX/macMLX/App/AppState.swift index 3e38070..90b59bf 100644 --- a/macMLX/macMLX/App/AppState.swift +++ b/macMLX/macMLX/App/AppState.swift @@ -221,6 +221,18 @@ public final class AppState { ) server = instance serverPort = actualPort + // Share discovery with CLI (issue #—, v0.3.7): the CLI's + // `serve` command reads this file and refuses to double-bind + // when the GUI is already serving. `try?` because losing + // the pid file is non-fatal — the server itself started fine. + let record = PIDFile.Record( + pid: Int32(ProcessInfo.processInfo.processIdentifier), + port: actualPort, + modelID: coordinator.currentModel?.id, + startedAt: Date(), + owner: .gui + ) + try? PIDFile.write(record) await logs.log( "HTTP server started on http://localhost:\(actualPort)/v1", level: .info, @@ -243,6 +255,9 @@ public final class AppState { await instance.stop() server = nil serverPort = nil + // Release the shared discovery file so CLI `serve` stops + // reporting the GUI as the owner. `try?` — absent file is fine. + try? PIDFile.clear() await logs.log( "HTTP server stopped", level: .info, diff --git a/macmlx-cli/Sources/macmlx/Commands/PSCommand.swift b/macmlx-cli/Sources/macmlx/Commands/PSCommand.swift index a612e85..1e88b2f 100644 --- a/macmlx-cli/Sources/macmlx/Commands/PSCommand.swift +++ b/macmlx-cli/Sources/macmlx/Commands/PSCommand.swift @@ -1,6 +1,7 @@ import ArgumentParser import Darwin import Foundation +import MacMLXCore struct PSCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( @@ -38,6 +39,7 @@ struct PSCommand: AsyncParsableCommand { } else { print("macmlx serve is running") print(" PID: \(record.pid)") + print(" Owner: \(record.owner.rawValue.uppercased())") print(" Port: \(record.port)") print(" Model: \(record.modelID ?? "(none)")") print(" Uptime: \(uptimeStr)") diff --git a/macmlx-cli/Sources/macmlx/Commands/ServeCommand.swift b/macmlx-cli/Sources/macmlx/Commands/ServeCommand.swift index f2490c8..2fbefd1 100644 --- a/macmlx-cli/Sources/macmlx/Commands/ServeCommand.swift +++ b/macmlx-cli/Sources/macmlx/Commands/ServeCommand.swift @@ -44,6 +44,19 @@ struct ServeCommand: AsyncParsableCommand { let models = (try? await library.scan(modelDirectory)) ?? [] return models.first { $0.id == modelID || $0.displayName == modelID } } + // Refuse to double-bind if another macMLX process (GUI or CLI) + // is already serving. `kill(pid, 0)` is the POSIX "is this PID + // alive?" probe — returns 0 when the process exists. A stale + // PID file (process gone) falls through and gets overwritten. + if let existing = try? PIDFile.read(), + kill(existing.pid, 0) == 0 { + let ownerLabel = existing.owner == .gui ? "GUI" : "CLI" + throw ValidationError(""" + Another macMLX server is already running on :\(existing.port) (\(ownerLabel), PID \(existing.pid)). + Close the \(ownerLabel) or run `macmlx stop` first. + """) + } + let server = HummingbirdServer(engine: engine, modelResolver: resolver) let actualPort = try await server.start(preferredPort: port) @@ -52,7 +65,8 @@ struct ServeCommand: AsyncParsableCommand { pid: getpid(), port: actualPort, modelID: model, - startedAt: startedAt + startedAt: startedAt, + owner: .cli ) try PIDFile.write(record) diff --git a/macmlx-cli/Sources/macmlx/Commands/StopCommand.swift b/macmlx-cli/Sources/macmlx/Commands/StopCommand.swift index f57a3a3..516684c 100644 --- a/macmlx-cli/Sources/macmlx/Commands/StopCommand.swift +++ b/macmlx-cli/Sources/macmlx/Commands/StopCommand.swift @@ -1,6 +1,7 @@ import ArgumentParser import Darwin import Foundation +import MacMLXCore struct StopCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( diff --git a/macmlx-cli/Tests/macmlxTests/PSCommandTests.swift b/macmlx-cli/Tests/macmlxTests/PSCommandTests.swift index a10fc23..94eb0a3 100644 --- a/macmlx-cli/Tests/macmlxTests/PSCommandTests.swift +++ b/macmlx-cli/Tests/macmlxTests/PSCommandTests.swift @@ -1,5 +1,6 @@ import Testing import Foundation +import MacMLXCore @testable import macmlx /// Tests for the `macmlx ps` command. @@ -15,7 +16,8 @@ struct PSCommandTests { pid: 12345, port: 8000, modelID: "Qwen3-8B-4bit", - startedAt: Date(timeIntervalSince1970: 1_700_000_000) + startedAt: Date(timeIntervalSince1970: 1_700_000_000), + owner: .cli ) let encoder = JSONEncoder() @@ -39,7 +41,8 @@ struct PSCommandTests { pid: 99, port: 8001, modelID: nil, - startedAt: Date() + startedAt: Date(), + owner: .cli ) let encoder = JSONEncoder() From 935d0bc7bd2989894f089941bafc4aa7c8163530 Mon Sep 17 00:00:00 2001 From: Kefeng Zhou Date: Sat, 18 Apr 2026 20:49:57 +0700 Subject: [PATCH 6/7] =?UTF-8?q?docs:=20v0.3.7=20changelog=20entry=20(4=20i?= =?UTF-8?q?tems=20=E2=80=94=20CI=20Node=2024,=20stdout=20capture,=20update?= =?UTF-8?q?=20detection,=20shared=20PIDFile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebfd479..48629e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,48 @@ Versioning follows [Semantic Versioning](https://semver.org/). --- +## [0.3.7] - 2026-04-18 + +Maintenance release — four items agreed after the v0.3.6 post-QA pass. + +### Added +- **MLX stdout/stderr → Logs tab.** `StdoutCapture.install()` dups + the process's STDOUT/STDERR to a Pipe at launch, tees each line + to both the original fd (terminal visibility preserved) and + `LogManager.shared.debug(category: .system)`. Users reporting + "model is slow" can now see what `mlx-swift-lm` is printing + without attaching a debugger. +- **HF model-update detection.** Downloaded models now get a + `.macmlx-meta.json` sidecar recording the Hub's commit SHA + + `lastModified` at download time. Models tab throttle-checks + (once per 24 h) each sidecar against `/api/models/{id}`. Rows + whose Hub head has advanced get an orange "Update available" + badge. Delete-and-re-download is the update action for now. +- **Shared `~/.mac-mlx/macmlx.pid`.** `PIDFile` moved from the CLI + target into `MacMLXCore`; `Record.owner` enum (`.gui | .cli`) + distinguishes the two. GUI writes the file on `startServer()`, + clears it on `stopServer()`. CLI's `macmlx serve` pre-flights + with `kill(pid, 0)` and refuses to double-bind when a live + record exists, naming the owner in the error so users know + which process to close. `macmlx ps` now shows "Owner: GUI | CLI". + +### Changed +- **GitHub Actions pinned to Node.js 24.** `actions/checkout` and + `actions/cache` bumped from `@v4` (Node 20, deprecated) to `@v5` + (Node 24). Silences the deprecation warning that surfaced on the + v0.3.6 release run. + +### Notes +- **Full Ollama-style daemon mode** (CLI commands proxied through + the GUI's HTTP API rather than spinning their own engine) remains + a future scope. This release just gets them to stop stepping on + each other at the port-binding layer. +- **Backward compatibility**: pre-v0.3.7 PID files (no `owner` key) + decode as `.cli`. Pre-v0.3.7 downloads without sidecars simply + don't get an update-check until the user re-downloads them once. + +--- + ## [0.3.6] - 2026-04-18 Bug-fix and UX-polish release covering thirteen user-reported issues From c68e94aa53117ba0f3cae8883b5a9aef2f205e8e Mon Sep 17 00:00:00 2001 From: Kefeng Zhou Date: Sat, 18 Apr 2026 20:54:06 +0700 Subject: [PATCH 7/7] fix(logs): mark StdoutCapture.installed nonisolated(unsafe) Swift 6 strict concurrency (default on in CI's Xcode 16.4) rejects 'static var installed' as non-concurrency-safe mutable global state. The flag is touched exactly once from macMLXApp.init() on the main thread before any other code runs, so nonisolated(unsafe) is the accurate annotation. Local dev build missed this because DerivedData was still cached from before StdoutCapture existed. --- macMLX/macMLX/App/StdoutCapture.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/macMLX/macMLX/App/StdoutCapture.swift b/macMLX/macMLX/App/StdoutCapture.swift index 8b75d49..ddf9db0 100644 --- a/macMLX/macMLX/App/StdoutCapture.swift +++ b/macMLX/macMLX/App/StdoutCapture.swift @@ -11,8 +11,11 @@ import MacMLXCore enum StdoutCapture { /// True once installed so repeat calls (e.g. from SwiftUI previews) - /// are no-ops instead of double-redirecting. - private static var installed = false + /// are no-ops instead of double-redirecting. `nonisolated(unsafe)` + /// is the right choice here: this flag is touched exactly once from + /// `macMLXApp.init()` on the main thread before any other code + /// runs, so there is no real concurrency exposure. + nonisolated(unsafe) private static var installed = false /// Redirect STDOUT_FILENO and STDERR_FILENO to a Pipe. Launch a /// background task that reads the pipe and forwards each line to: