Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions LilAgents/AgentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"
}
}

Expand All @@ -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()
}
}
}
Expand Down
196 changes: 196 additions & 0 deletions LilAgents/StakpakSession.swift
Original file line number Diff line number Diff line change
@@ -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: "")
}
}
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ 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)

## features

- 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
Expand All @@ -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

Expand All @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions lil-agents.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -45,6 +46,7 @@
A10000020000000000000032 /* CodexSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexSession.swift; sourceTree = "<group>"; };
A10000020000000000000033 /* CopilotSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopilotSession.swift; sourceTree = "<group>"; };
A10000020000000000000034 /* GeminiSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiSession.swift; sourceTree = "<group>"; };
A10000020000000000000035 /* StakpakSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StakpakSession.swift; sourceTree = "<group>"; };
A10000030000000000000001 /* lil agents.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "lil agents.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down