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 }} 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 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/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/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/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? 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/macMLX/App/StdoutCapture.swift b/macMLX/macMLX/App/StdoutCapture.swift new file mode 100644 index 0000000..ddf9db0 --- /dev/null +++ b/macMLX/macMLX/App/StdoutCapture.swift @@ -0,0 +1,79 @@ +// 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. `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: + /// - 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.. 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 { diff --git a/macMLX/macMLX/macMLXApp.swift b/macMLX/macMLX/macMLXApp.swift index 3341d07..cdc3624 100644 --- a/macMLX/macMLX/macMLXApp.swift +++ b/macMLX/macMLX/macMLXApp.swift @@ -13,7 +13,15 @@ import Sparkle @main struct macMLXApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - @State private var appState = AppState() + @State private var appState: AppState + + init() { + // Redirect MLX library prints into the Logs tab. Must happen + // before any MLX code runs — AppState.init creates the engine + // immediately, which triggers the first mlx-swift prints. + StdoutCapture.install() + _appState = State(initialValue: AppState()) + } var body: some Scene { // Identified WindowGroup so the menu bar "Open" button can call 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()