From 6507011a0bfa3f1cff5e1c8fd9ab7e29736e4725 Mon Sep 17 00:00:00 2001 From: noureldin-azzab Date: Tue, 31 Mar 2026 20:30:41 +0300 Subject: [PATCH] feat: add Stakpak as a supported AI provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds StakpakSession.swift which integrates the Stakpak CLI as a fifth provider alongside Claude, Codex, Copilot, and Gemini. How it works: - Spawns `stakpak -a --max-steps 20 --config ` as a subprocess per message, using the same Process/Pipe pattern as the other sessions - Passes `--config` with an absolute path to ensure the config is found when launched outside a login shell environment - Preserves multi-turn context by prepending conversation history into the prompt on each invocation - Strips ANSI escape codes, box-drawing chrome (┌─ │ └─), the Session Usage table, and internal [warning]/[info] lines from stdout before rendering in the terminal view Changes: - LilAgents/StakpakSession.swift — new session implementation (194 lines) - LilAgents/AgentSession.swift — added .stakpak case to AgentProvider enum with displayName, installInstructions, and createSession() - lil-agents.xcodeproj/project.pbxproj — registered StakpakSession.swift as a build source - README.md — added Stakpak to supported providers list and install instructions --- LilAgents/AgentSession.swift | 22 +-- LilAgents/StakpakSession.swift | 196 +++++++++++++++++++++++++++ README.md | 7 +- lil-agents.xcodeproj/project.pbxproj | 4 + 4 files changed, 217 insertions(+), 12 deletions(-) create mode 100644 LilAgents/StakpakSession.swift diff --git a/LilAgents/AgentSession.swift b/LilAgents/AgentSession.swift index ecb4d0a..476151b 100644 --- a/LilAgents/AgentSession.swift +++ b/LilAgents/AgentSession.swift @@ -3,7 +3,7 @@ import Foundation // MARK: - Provider enum AgentProvider: String, CaseIterable { - case claude, codex, copilot, gemini + case claude, codex, copilot, gemini, stakpak private static let defaultsKey = "selectedProvider" @@ -19,10 +19,11 @@ enum AgentProvider: String, CaseIterable { var displayName: String { switch self { - case .claude: return "Claude" - case .codex: return "Codex" - case .copilot: return "Copilot" - case .gemini: return "Gemini" + case .claude: return "Claude" + case .codex: return "Codex" + case .copilot: return "Copilot" + case .gemini: return "Gemini" + case .stakpak: return "Stakpak" } } @@ -49,15 +50,18 @@ enum AgentProvider: String, CaseIterable { return "To install, run this in Terminal:\n brew install copilot-cli\n\nOr: npm install -g @github/copilot-cli" case .gemini: return "To install, run this in Terminal:\n npm install -g @google/gemini-cli\n\nThen authenticate:\n gemini auth" + case .stakpak: + return "To install, run this in Terminal:\n curl -sSL https://stakpak.dev/install.sh | sh\n\nThen authenticate:\n stakpak auth login" } } func createSession() -> any AgentSession { switch self { - case .claude: return ClaudeSession() - case .codex: return CodexSession() - case .copilot: return CopilotSession() - case .gemini: return GeminiSession() + case .claude: return ClaudeSession() + case .codex: return CodexSession() + case .copilot: return CopilotSession() + case .gemini: return GeminiSession() + case .stakpak: return StakpakSession() } } } diff --git a/LilAgents/StakpakSession.swift b/LilAgents/StakpakSession.swift new file mode 100644 index 0000000..a74a8f1 --- /dev/null +++ b/LilAgents/StakpakSession.swift @@ -0,0 +1,196 @@ +import Foundation + +class StakpakSession: AgentSession { + private var process: Process? + private var currentResponseText = "" + private(set) var isRunning = false + private(set) var isBusy = false + private static var binaryPath: String? + + var onText: ((String) -> Void)? + var onError: ((String) -> Void)? + var onToolUse: ((String, [String: Any]) -> Void)? + var onToolResult: ((String, Bool) -> Void)? + var onSessionReady: (() -> Void)? + var onTurnComplete: (() -> Void)? + var onProcessExit: (() -> Void)? + + var history: [AgentMessage] = [] + + // MARK: - Process Lifecycle + + func start() { + if let cached = Self.binaryPath { + isRunning = true + DispatchQueue.main.async { self.onSessionReady?() } + return + } + + let home = FileManager.default.homeDirectoryForCurrentUser.path + ShellEnvironment.findBinary(name: "stakpak", fallbackPaths: [ + "\(home)/.local/bin/stakpak", + "/usr/local/bin/stakpak", + "/opt/homebrew/bin/stakpak" + ]) { [weak self] path in + guard let self = self, let binaryPath = path else { + let msg = "Stakpak CLI not found.\n\n\(AgentProvider.stakpak.installInstructions)" + self?.onError?(msg) + self?.history.append(AgentMessage(role: .error, text: msg)) + return + } + Self.binaryPath = binaryPath + self.isRunning = true + self.onSessionReady?() + } + } + + func send(message: String) { + guard isRunning, let binaryPath = Self.binaryPath else { return } + + isBusy = true + currentResponseText = "" + history.append(AgentMessage(role: .user, text: message)) + + // Build prompt: prepend conversation history so stakpak has context + var prompt = "" + let contextMessages = history.dropLast() // exclude the message we just added + if !contextMessages.isEmpty { + prompt += "Here is our conversation so far:\n" + for msg in contextMessages { + switch msg.role { + case .user: + prompt += "User: \(msg.text)\n" + case .assistant: + prompt += "Assistant: \(msg.text)\n" + case .error, .toolUse, .toolResult: + break + } + } + prompt += "\nNow respond to:\n" + } + prompt += message + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: binaryPath) + let home = FileManager.default.homeDirectoryForCurrentUser.path + let configPath = "\(home)/.stakpak/config.toml" + proc.arguments = ["-a", "--max-steps", "20", "--config", configPath, prompt] + proc.currentDirectoryURL = FileManager.default.homeDirectoryForCurrentUser + proc.environment = ShellEnvironment.processEnvironment(extraPaths: ["/opt/homebrew/bin"]) + + let outPipe = Pipe() + let errPipe = Pipe() + proc.standardOutput = outPipe + proc.standardError = errPipe + + proc.terminationHandler = { [weak self] _ in + DispatchQueue.main.async { + guard let self = self else { return } + self.isBusy = false + // Clean the full buffered response: filter chrome lines, strip box prefix + let lines = self.currentResponseText.components(separatedBy: "\n") + let cleanLines = lines + .filter { !self.isChromeLine($0) } + .map { self.stripBoxPrefix($0) } + let finalText = cleanLines.joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + if !finalText.isEmpty { + self.history.append(AgentMessage(role: .assistant, text: finalText)) + } + self.currentResponseText = "" + self.onTurnComplete?() + } + } + + outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if let text = String(data: data, encoding: .utf8) { + DispatchQueue.main.async { + self?.processOutput(text) + } + } + } + + errPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + // Stakpak writes progress/status info to stderr — swallow it silently + // so it doesn't bleed into the chat view. Uncomment the line below + // to surface stderr errors to the user if needed. + // if let text = String(data: data, encoding: .utf8) { + // DispatchQueue.main.async { self?.onError?(text) } + // } + } + + do { + try proc.run() + process = proc + } catch { + let msg = "Failed to launch Stakpak.\n\n\(AgentProvider.stakpak.installInstructions)\n\nError: \(error.localizedDescription)" + onError?(msg) + history.append(AgentMessage(role: .error, text: msg)) + isBusy = false + } + } + + func terminate() { + process?.terminate() + process = nil + isRunning = false + isBusy = false + } + + // MARK: - Output Processing + + private func processOutput(_ text: String) { + // Strip ANSI escape codes (stakpak outputs colored terminal text) + let clean = stripANSI(text) + // Buffer all output, then clean up chrome in terminationHandler + currentResponseText += clean + // Stream lines that aren't UI chrome immediately + for line in clean.components(separatedBy: "\n") { + if !isChromeLine(line) { + let stripped = stripBoxPrefix(line) + onText?(stripped + "\n") + } + } + } + + /// Returns true for lines that are stakpak UI chrome, not actual response content. + private func isChromeLine(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + // Box-drawing borders: ┌─ Final Agent Response, │ content, └─── + if trimmed.hasPrefix("┌") || trimmed.hasPrefix("└") { return true } + // Session Usage block + if trimmed == "Session Usage" { return false } // let it fall through to footer filter + // "To resume, run:" and session ID lines + if trimmed.hasPrefix("To resume, run:") { return true } + if trimmed.hasPrefix("stakpak -s ") { return true } + if trimmed.hasPrefix("Session ID:") { return true } + // Token usage table lines (start with Model, Prompt tokens, etc.) + let usageLabels = ["Model", "Prompt tokens", "├─", "└─", "Completion tokens", "Total tokens", "Session Usage"] + if usageLabels.contains(where: { trimmed.hasPrefix($0) }) { return true } + // Stakpak internal warnings + if trimmed.hasPrefix("[warning]") || trimmed.hasPrefix("[info]") { return true } + return false + } + + /// Remove the leading "│ " box prefix from content lines inside the response box. + private func stripBoxPrefix(_ line: String) -> String { + var s = line + // Remove leading │ and optional space + if s.hasPrefix("│ ") { s = String(s.dropFirst(2)) } + else if s.hasPrefix("│") { s = String(s.dropFirst(1)) } + return s + } + + /// Remove ANSI escape sequences from terminal output. + private func stripANSI(_ text: String) -> String { + guard let regex = try? NSRegularExpression(pattern: "\\x1B(?:\\[[0-9;]*[A-Za-z]|[^\\[])") else { + return text + } + let range = NSRange(text.startIndex..., in: text) + return regex.stringByReplacingMatches(in: text, range: range, withTemplate: "") + } +} diff --git a/README.md b/README.md index 7b996d8..b2d7c20 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Tiny AI companions that live on your macOS dock. **Bruce** and **Jazz** walk back and forth above your dock. Click one to open an AI terminal. They walk, they think, they vibe. -Supports **Claude Code**, **OpenAI Codex**, **GitHub Copilot**, and **Google Gemini** CLIs — switch between them from the menubar. +Supports **Claude Code**, **OpenAI Codex**, **GitHub Copilot**, **Google Gemini**, and **Stakpak** CLIs — switch between them from the menubar. **[Download for macOS](https://lilagents.xyz)** · [Website](https://lilagents.xyz) @@ -14,7 +14,7 @@ Supports **Claude Code**, **OpenAI Codex**, **GitHub Copilot**, and **Google Gem - Animated characters rendered from transparent HEVC video - Click a character to chat with AI in a themed popover terminal -- Switch between Claude, Codex, Copilot, and Gemini from the menubar +- Switch between Claude, Codex, Copilot, Gemini, and Stakpak from the menubar - Four visual themes: Peach, Midnight, Cloud, Moss - Slash commands: `/clear`, `/copy`, `/help` in the chat input - Copy last response button in the title bar @@ -32,6 +32,7 @@ Supports **Claude Code**, **OpenAI Codex**, **GitHub Copilot**, and **Google Gem - [OpenAI Codex](https://github.com/openai/codex) — `npm install -g @openai/codex` - [GitHub Copilot](https://github.com/github/copilot-cli) — `brew install copilot-cli` - [Google Gemini CLI](https://github.com/google-gemini/gemini-cli) — `npm install -g @google/gemini-cli` + - [Stakpak](https://stakpak.dev) — `curl -sSL https://stakpak.dev/install.sh | sh` then `stakpak auth login` ## building @@ -42,7 +43,7 @@ Open `lil-agents.xcodeproj` in Xcode and hit run. lil agents runs entirely on your Mac and sends no personal data anywhere. - **Your data stays local.** The app plays bundled animations and calculates your dock size to position the characters. No project data, file paths, or personal information is collected or transmitted. -- **AI providers.** Conversations are handled entirely by the CLI process you choose (Claude, Codex, Copilot, or Gemini) running locally. lil agents does not intercept, store, or transmit your chat content. Any data sent to the provider is governed by their respective terms and privacy policies. +- **AI providers.** Conversations are handled entirely by the CLI process you choose (Claude, Codex, Copilot, Gemini, or Stakpak) running locally. lil agents does not intercept, store, or transmit your chat content. Any data sent to the provider is governed by their respective terms and privacy policies. - **No accounts.** No login, no user database, no analytics in the app. - **Updates.** lil agents uses Sparkle to check for updates, which sends your app version and macOS version. Nothing else. diff --git a/lil-agents.xcodeproj/project.pbxproj b/lil-agents.xcodeproj/project.pbxproj index 6cdde8a..03e85e9 100644 --- a/lil-agents.xcodeproj/project.pbxproj +++ b/lil-agents.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ A10000010000000000000033 /* CodexSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000032 /* CodexSession.swift */; }; A10000010000000000000034 /* CopilotSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000033 /* CopilotSession.swift */; }; A10000010000000000000035 /* GeminiSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000034 /* GeminiSession.swift */; }; + A10000010000000000000036 /* StakpakSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000035 /* StakpakSession.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -45,6 +46,7 @@ A10000020000000000000032 /* CodexSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexSession.swift; sourceTree = ""; }; A10000020000000000000033 /* CopilotSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopilotSession.swift; sourceTree = ""; }; A10000020000000000000034 /* GeminiSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiSession.swift; sourceTree = ""; }; + A10000020000000000000035 /* StakpakSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakpakSession.swift; sourceTree = ""; }; A10000030000000000000001 /* lil agents.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "lil agents.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -80,6 +82,7 @@ A10000020000000000000032 /* CodexSession.swift */, A10000020000000000000033 /* CopilotSession.swift */, A10000020000000000000034 /* GeminiSession.swift */, + A10000020000000000000035 /* StakpakSession.swift */, A10000020000000000000023 /* TerminalView.swift */, A10000020000000000000024 /* WalkerCharacter.swift */, A10000020000000000000025 /* LilAgentsController.swift */, @@ -188,6 +191,7 @@ A10000010000000000000033 /* CodexSession.swift in Sources */, A10000010000000000000034 /* CopilotSession.swift in Sources */, A10000010000000000000035 /* GeminiSession.swift in Sources */, + A10000010000000000000036 /* StakpakSession.swift in Sources */, A10000010000000000000023 /* TerminalView.swift in Sources */, A10000010000000000000024 /* WalkerCharacter.swift in Sources */, A10000010000000000000025 /* LilAgentsController.swift in Sources */,