diff --git a/LilAgents/AgentSession.swift b/LilAgents/AgentSession.swift index ecb4d0a..08e9f19 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, live private static let defaultsKey = "selectedProvider" @@ -23,11 +23,15 @@ enum AgentProvider: String, CaseIterable { case .codex: return "Codex" case .copilot: return "Copilot" case .gemini: return "Gemini" + case .live: return "Live Session" } } var inputPlaceholder: String { - "Ask \(displayName)..." + switch self { + case .live: return "Send a note to the session..." + default: return "Ask \(displayName)..." + } } /// Returns provider name styled per theme format. @@ -49,17 +53,32 @@ 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 .live: + return "To use Live Session, install the bridge hook.\nSee: hooks/lil-agents-bridge.mjs in the project." } } + /// Whether this provider requires session selection before use. + var requiresSessionPicker: Bool { + self == .live && Self.selectedLiveSession == nil + } + + /// The currently selected live session (set via menu bar). + static var selectedLiveSession: LiveSession.DiscoveredSession? + func createSession() -> any AgentSession { switch self { case .claude: return ClaudeSession() case .codex: return CodexSession() case .copilot: return CopilotSession() case .gemini: return GeminiSession() + case .live: fatalError("Use createLiveSession(sessionId:projectName:) instead") } } + + func createLiveSession(sessionId: String, projectName: String) -> LiveSession { + LiveSession(sessionId: sessionId, projectName: projectName) + } } // MARK: - Title Format diff --git a/LilAgents/LilAgentsApp.swift b/LilAgents/LilAgentsApp.swift index f1f22a4..efad6da 100644 --- a/LilAgents/LilAgentsApp.swift +++ b/LilAgents/LilAgentsApp.swift @@ -54,11 +54,23 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Provider submenu let providerItem = NSMenuItem(title: "Provider", action: nil, keyEquivalent: "") let providerMenu = NSMenu() + providerMenu.delegate = self for (i, provider) in AgentProvider.allCases.enumerated() { - let item = NSMenuItem(title: provider.displayName, action: #selector(switchProvider(_:)), keyEquivalent: "") - item.tag = i - item.state = provider == AgentProvider.current ? .on : .off - providerMenu.addItem(item) + if provider == .live { + // Live Session gets a submenu with discovered sessions + let liveItem = NSMenuItem(title: provider.displayName, action: nil, keyEquivalent: "") + liveItem.tag = i + liveItem.state = provider == AgentProvider.current ? .on : .off + let liveMenu = NSMenu() + liveMenu.delegate = self + liveItem.submenu = liveMenu + providerMenu.addItem(liveItem) + } else { + let item = NSMenuItem(title: provider.displayName, action: #selector(switchProvider(_:)), keyEquivalent: "") + item.tag = i + item.state = provider == AgentProvider.current ? .on : .off + providerMenu.addItem(item) + } } providerItem.submenu = providerMenu menu.addItem(providerItem) @@ -226,4 +238,76 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } -extension AppDelegate: NSMenuDelegate {} +extension AppDelegate: NSMenuDelegate { + func menuNeedsUpdate(_ menu: NSMenu) { + // Check if this is the Live Session submenu + if let parentMenu = statusItem?.menu?.item(withTitle: "Provider")?.submenu { + for item in parentMenu.items where item.title == "Live Session" && item.submenu === menu { + rebuildLiveSessionMenu(menu) + return + } + } + } + + private func rebuildLiveSessionMenu(_ menu: NSMenu) { + menu.removeAllItems() + + let sessions = LiveSession.discoverSessions() + let selectedId = AgentProvider.selectedLiveSession?.id + + if sessions.isEmpty { + let emptyItem = NSMenuItem(title: "No active sessions", action: nil, keyEquivalent: "") + emptyItem.isEnabled = false + menu.addItem(emptyItem) + } else { + for (i, session) in sessions.enumerated() { + let title = "\(session.projectName) — \(session.age)" + let item = NSMenuItem(title: title, action: #selector(selectLiveSession(_:)), keyEquivalent: "") + item.tag = i + item.state = session.id == selectedId ? .on : .off + item.representedObject = session.id + item.toolTip = session.cwd + menu.addItem(item) + } + } + + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "Refresh", action: #selector(refreshLiveSessions(_:)), keyEquivalent: "")) + } + + @objc func selectLiveSession(_ sender: NSMenuItem) { + let sessions = LiveSession.discoverSessions() + guard sender.tag < sessions.count else { return } + let chosen = sessions[sender.tag] + + // Set provider to .live and store selected session + AgentProvider.current = .live + AgentProvider.selectedLiveSession = chosen + + // Update provider menu checkmarks + if let providerMenu = statusItem?.menu?.item(withTitle: "Provider")?.submenu { + let liveIdx = AgentProvider.allCases.firstIndex(of: .live)! + for item in providerMenu.items { + item.state = item.tag == liveIdx ? .on : .off + } + } + + // Terminate existing sessions and reconnect with new live session + controller?.characters.forEach { char in + char.session?.terminate() + char.session = nil + if char.isIdleForPopover { + char.closePopover() + } + char.popoverWindow?.orderOut(nil) + char.popoverWindow = nil + char.terminalView = nil + char.thinkingBubbleWindow?.orderOut(nil) + char.thinkingBubbleWindow = nil + } + } + + @objc func refreshLiveSessions(_ sender: NSMenuItem) { + // The menu will rebuild on next open via menuNeedsUpdate + } +} diff --git a/LilAgents/LilAgentsController.swift b/LilAgents/LilAgentsController.swift index 980f000..4033d05 100644 --- a/LilAgents/LilAgentsController.swift +++ b/LilAgents/LilAgentsController.swift @@ -14,14 +14,14 @@ class LilAgentsController { char1.fullSpeedStart = 3.75 char1.decelStart = 8.0 char1.walkStop = 8.5 - char1.walkAmountRange = 0.4...0.65 + char1.walkAmountRange = 0.5...0.9 let char2 = WalkerCharacter(videoName: "walk-jazz-01") char2.accelStart = 3.9 char2.fullSpeedStart = 4.5 char2.decelStart = 8.0 char2.walkStop = 8.75 - char2.walkAmountRange = 0.35...0.6 + char2.walkAmountRange = 0.5...0.9 char1.yOffset = -3 char2.yOffset = -7 char1.characterColor = NSColor(red: 0.4, green: 0.72, blue: 0.55, alpha: 1.0) @@ -34,7 +34,7 @@ class LilAgentsController { char2.positionProgress = 0.7 char1.pauseEndTime = CACurrentMediaTime() + Double.random(in: 0.5...2.0) - char2.pauseEndTime = CACurrentMediaTime() + Double.random(in: 8.0...14.0) + char2.pauseEndTime = CACurrentMediaTime() + Double.random(in: 2.0...5.0) char1.setup() char2.setup() @@ -203,8 +203,10 @@ class LilAgentsController { let dockWidth: CGFloat let dockTopY: CGFloat - // Dock is on this screen — constrain to dock area - (dockX, dockWidth) = getDockIconArea(screenWidth: screenWidth) + // Let characters roam the full screen width with small padding + let padding: CGFloat = 20 + dockX = screen.frame.origin.x + padding + dockWidth = screenWidth - padding * 2 dockTopY = screen.visibleFrame.origin.y updateDebugLine(dockX: dockX, dockWidth: dockWidth, dockTopY: dockTopY) @@ -212,13 +214,7 @@ class LilAgentsController { let activeChars = characters.filter { $0.window.isVisible && $0.isManuallyVisible } let now = CACurrentMediaTime() - let anyWalking = activeChars.contains { $0.isWalking } - for char in activeChars { - if char.isIdleForPopover { continue } - if char.isPaused && now >= char.pauseEndTime && anyWalking { - char.pauseEndTime = now + Double.random(in: 5.0...10.0) - } - } + // Allow both characters to walk simultaneously — no blocking for char in activeChars { char.update(dockX: dockX, dockWidth: dockWidth, dockTopY: dockTopY) } diff --git a/LilAgents/LiveSession.swift b/LilAgents/LiveSession.swift new file mode 100644 index 0000000..71c7bdb --- /dev/null +++ b/LilAgents/LiveSession.swift @@ -0,0 +1,289 @@ +import Foundation + +/// Observes an active Claude Code session by watching its event file, +/// and can send messages into the session via an inbox mechanism. +class LiveSession: AgentSession { + private(set) var isRunning = false + private(set) var isBusy = false + var history: [AgentMessage] = [] + + 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)? + + let sessionId: String + let projectName: String + private var fileHandle: FileHandle? + private var dispatchSource: DispatchSourceFileSystemObject? + private var lineBuffer = "" + private var pollTimer: Timer? + + private static let baseDir: String = { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return "\(home)/.claude/lil-agents" + }() + + static var sessionsDir: String { "\(baseDir)/sessions" } + private static var inboxDir: String { "\(baseDir)/inbox" } + + init(sessionId: String, projectName: String = "") { + self.sessionId = sessionId + self.projectName = projectName + } + + // MARK: - Session Discovery + + struct DiscoveredSession { + let id: String + let cwd: String + let startedAt: Date + let lastEvent: Date + + var projectName: String { + (cwd as NSString).lastPathComponent + } + + var age: String { + let seconds = Int(Date().timeIntervalSince(lastEvent)) + if seconds < 60 { return "\(seconds)s ago" } + let minutes = seconds / 60 + if minutes < 60 { return "\(minutes)m ago" } + return "\(minutes / 60)h ago" + } + + var isStale: Bool { + Date().timeIntervalSince(lastEvent) > 600 // 10 minutes + } + } + + static func discoverSessions() -> [DiscoveredSession] { + let fm = FileManager.default + let dir = sessionsDir + + // Ensure dir exists + try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true) + + guard let files = try? fm.contentsOfDirectory(atPath: dir) else { return [] } + + var sessions: [DiscoveredSession] = [] + for file in files where file.hasSuffix(".meta") { + let path = "\(dir)/\(file)" + guard let data = fm.contents(atPath: path), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let id = json["session_id"] as? String else { continue } + + let cwd = json["cwd"] as? String ?? "~" + let startedMs = json["started_at"] as? Double ?? 0 + let lastEventMs = json["last_event"] as? Double ?? 0 + + let session = DiscoveredSession( + id: id, + cwd: cwd, + startedAt: Date(timeIntervalSince1970: startedMs / 1000), + lastEvent: Date(timeIntervalSince1970: lastEventMs / 1000) + ) + + if !session.isStale { + sessions.append(session) + } + } + + return sessions.sorted { $0.lastEvent > $1.lastEvent } + } + + // MARK: - Lifecycle + + func start() { + let sessionFile = "\(Self.sessionsDir)/\(sessionId).jsonl" + + guard FileManager.default.fileExists(atPath: sessionFile) else { + onError?("Session file not found. Is the Claude Code hook installed?") + return + } + + guard let handle = FileHandle(forReadingAtPath: sessionFile) else { + onError?("Cannot open session file.") + return + } + + fileHandle = handle + isRunning = true + + // Replay recent events for context + replayRecentEvents(from: sessionFile) + + // Seek to end for live watching + handle.seekToEndOfFile() + + // Watch for file changes using DispatchSource + let fd = handle.fileDescriptor + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .extend, .attrib], + queue: .main + ) + + source.setEventHandler { [weak self] in + self?.readNewEvents() + } + + source.setCancelHandler { [weak self] in + self?.fileHandle?.closeFile() + self?.fileHandle = nil + } + + dispatchSource = source + source.resume() + + // Also poll periodically as a fallback (DispatchSource can miss appends) + pollTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.readNewEvents() + } + + onSessionReady?() + } + + private func replayRecentEvents(from path: String) { + guard let data = FileManager.default.contents(atPath: path), + let content = String(data: data, encoding: .utf8) else { return } + + let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty } + let recent = lines.suffix(15) + for line in recent { + parseLine(line) + } + } + + private func readNewEvents() { + guard let handle = fileHandle else { return } + + let data = handle.readDataToEndOfFile() + guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return } + + lineBuffer += text + while let newlineRange = lineBuffer.range(of: "\n") { + let line = String(lineBuffer[lineBuffer.startIndex.. String { + switch toolName { + case "Bash": + return input["command"] as? String ?? "" + case "Read": + return input["file_path"] as? String ?? "" + case "Edit", "Write": + return input["file_path"] as? String ?? "" + case "Glob": + return input["pattern"] as? String ?? "" + case "Grep": + return input["pattern"] as? String ?? "" + default: + if let desc = input["description"] as? String { return desc } + return input.keys.sorted().prefix(3).joined(separator: ", ") + } + } +} diff --git a/LilAgents/TerminalView.swift b/LilAgents/TerminalView.swift index b540966..fb62092 100644 --- a/LilAgents/TerminalView.swift +++ b/LilAgents/TerminalView.swift @@ -189,16 +189,23 @@ class TerminalView: NSView { scrollToBottom() return true + case "/sessions": + // Handled by WalkerCharacter via onSendMessage — pass through + onSendMessage?("/sessions") + return true + case "/help": let t = theme let help = NSMutableAttributedString() help.append(NSAttributedString(string: " lil agents — slash commands\n", attributes: [.font: t.fontBold, .foregroundColor: t.accentColor])) - help.append(NSAttributedString(string: " /clear ", attributes: [.font: t.fontBold, .foregroundColor: t.textPrimary])) + help.append(NSAttributedString(string: " /clear ", attributes: [.font: t.fontBold, .foregroundColor: t.textPrimary])) help.append(NSAttributedString(string: "clear chat history\n", attributes: [.font: t.font, .foregroundColor: t.textDim])) - help.append(NSAttributedString(string: " /copy ", attributes: [.font: t.fontBold, .foregroundColor: t.textPrimary])) + help.append(NSAttributedString(string: " /copy ", attributes: [.font: t.fontBold, .foregroundColor: t.textPrimary])) help.append(NSAttributedString(string: "copy last response\n", attributes: [.font: t.font, .foregroundColor: t.textDim])) - help.append(NSAttributedString(string: " /help ", attributes: [.font: t.fontBold, .foregroundColor: t.textPrimary])) + help.append(NSAttributedString(string: " /sessions ", attributes: [.font: t.fontBold, .foregroundColor: t.textPrimary])) + help.append(NSAttributedString(string: "list active Claude Code sessions\n", attributes: [.font: t.font, .foregroundColor: t.textDim])) + help.append(NSAttributedString(string: " /help ", attributes: [.font: t.fontBold, .foregroundColor: t.textPrimary])) help.append(NSAttributedString(string: "show this message\n", attributes: [.font: t.font, .foregroundColor: t.textDim])) textView.textStorage?.append(help) scrollToBottom() diff --git a/LilAgents/WalkerCharacter.swift b/LilAgents/WalkerCharacter.swift index b854c2f..16a830c 100644 --- a/LilAgents/WalkerCharacter.swift +++ b/LilAgents/WalkerCharacter.swift @@ -39,6 +39,13 @@ class WalkerCharacter { var walkStartPixel: CGFloat = 0.0 var walkEndPixel: CGFloat = 0.0 + // Jump state + private var isJumping = false + private var jumpStartTime: CFTimeInterval = 0 + private let jumpDuration: CFTimeInterval = 0.45 + private let jumpHeight: CGFloat = 35 + private var nextJumpTime: CFTimeInterval = 0 + // Onboarding var isOnboarding = false @@ -50,6 +57,8 @@ class WalkerCharacter { var clickOutsideMonitor: Any? var escapeKeyMonitor: Any? var currentStreamingText = "" + private var pendingSessions: [LiveSession.DiscoveredSession] = [] + private var sessionPickerView: NSView? weak var controller: LilAgentsController? var themeOverride: PopoverTheme? var isAgentBusy: Bool { session?.isBusy ?? false } @@ -258,10 +267,22 @@ class WalkerCharacter { hideBubble() if session == nil { - let newSession = AgentProvider.current.createSession() - session = newSession - wireSession(newSession) - newSession.start() + if AgentProvider.current == .live, let chosen = AgentProvider.selectedLiveSession { + let liveSession = AgentProvider.live.createLiveSession( + sessionId: chosen.id, + projectName: chosen.projectName + ) + session = liveSession + wireSession(liveSession, providerName: "Live: \(chosen.projectName)") + liveSession.start() + } else if AgentProvider.current.requiresSessionPicker { + showSessionPicker() + } else { + let newSession = AgentProvider.current.createSession() + session = newSession + wireSession(newSession) + newSession.start() + } } if popoverWindow == nil { @@ -402,7 +423,12 @@ class WalkerCharacter { terminal.themeOverride = themeOverride terminal.autoresizingMask = [.width, .height] terminal.onSendMessage = { [weak self] message in - self?.session?.send(message: message) + guard let self = self else { return } + if !self.pendingSessions.isEmpty { + self.handleSessionPick(message) + } else { + self.session?.send(message: message) + } } terminal.onClearRequested = { [weak self] in self?.session?.history.removeAll() @@ -446,6 +472,158 @@ class WalkerCharacter { } } + // MARK: - Live Session Picker + + private func showSessionPicker() { + let sessions = LiveSession.discoverSessions() + pendingSessions = sessions + + if popoverWindow == nil { + createPopoverWindow() + } + + guard let container = popoverWindow?.contentView else { return } + let t = resolvedTheme + + // Remove old picker if present + sessionPickerView?.removeFromSuperview() + + let pickerHeight: CGFloat = 200 + let popoverWidth = container.bounds.width + let picker = NSView(frame: NSRect(x: 0, y: 0, width: popoverWidth, height: pickerHeight)) + picker.wantsLayer = true + picker.autoresizingMask = [.width] + + if sessions.isEmpty { + let label = NSTextField(wrappingLabelWithString: "no active sessions found.\n\nthe bridge hook is installed — start or use a Claude Code session and it will appear here.") + label.font = t.font + label.textColor = t.textDim + label.frame = NSRect(x: 20, y: pickerHeight - 90, width: popoverWidth - 40, height: 80) + label.isEditable = false + label.isBordered = false + label.drawsBackground = false + picker.addSubview(label) + + let refreshBtn = NSButton(title: "Refresh", target: self, action: #selector(refreshSessionPicker)) + refreshBtn.bezelStyle = .rounded + refreshBtn.frame = NSRect(x: popoverWidth / 2 - 40, y: pickerHeight - 130, width: 80, height: 28) + refreshBtn.contentTintColor = t.accentColor + picker.addSubview(refreshBtn) + } else { + let title = NSTextField(labelWithString: "connect to a session:") + title.font = t.fontBold + title.textColor = t.accentColor + title.frame = NSRect(x: 20, y: pickerHeight - 30, width: popoverWidth - 40, height: 20) + picker.addSubview(title) + + var yPos = pickerHeight - 60 + for (i, s) in sessions.enumerated() { + let btn = NSButton(frame: NSRect(x: 16, y: yPos, width: popoverWidth - 32, height: 44)) + btn.bezelStyle = .rounded + btn.title = "" + btn.tag = i + btn.target = self + btn.action = #selector(sessionButtonClicked(_:)) + btn.wantsLayer = true + btn.layer?.cornerRadius = 6 + picker.addSubview(btn) + + let nameLabel = NSTextField(labelWithString: s.projectName) + nameLabel.font = t.fontBold + nameLabel.textColor = t.textPrimary + nameLabel.frame = NSRect(x: 28, y: yPos + 22, width: popoverWidth - 80, height: 16) + nameLabel.isEditable = false + nameLabel.isBordered = false + nameLabel.drawsBackground = false + picker.addSubview(nameLabel) + + let detailLabel = NSTextField(labelWithString: "\(s.cwd) \u{2022} \(s.age)") + detailLabel.font = NSFont.systemFont(ofSize: t.font.pointSize - 1) + detailLabel.textColor = t.textDim + detailLabel.frame = NSRect(x: 28, y: yPos + 4, width: popoverWidth - 80, height: 14) + detailLabel.isEditable = false + detailLabel.isBordered = false + detailLabel.drawsBackground = false + detailLabel.lineBreakMode = .byTruncatingMiddle + picker.addSubview(detailLabel) + + yPos -= 52 + } + + let refreshBtn = NSButton(title: "Refresh", target: self, action: #selector(refreshSessionPicker)) + refreshBtn.bezelStyle = .rounded + refreshBtn.frame = NSRect(x: popoverWidth / 2 - 40, y: max(yPos, 8), width: 80, height: 28) + refreshBtn.contentTintColor = t.accentColor + picker.addSubview(refreshBtn) + } + + // Place picker over the terminal area (below title bar) + let terminalFrame = terminalView?.frame ?? NSRect(x: 0, y: 0, width: popoverWidth, height: pickerHeight) + picker.frame = terminalFrame + container.addSubview(picker, positioned: .above, relativeTo: terminalView) + sessionPickerView = picker + + // Hide input while picking + terminalView?.inputField.isHidden = true + } + + @objc private func refreshSessionPicker() { + showSessionPicker() + } + + @objc private func sessionButtonClicked(_ sender: NSButton) { + let idx = sender.tag + guard idx >= 0, idx < pendingSessions.count else { return } + connectToSession(pendingSessions[idx]) + } + + private func connectToSession(_ chosen: LiveSession.DiscoveredSession) { + pendingSessions = [] + sessionPickerView?.removeFromSuperview() + sessionPickerView = nil + terminalView?.inputField.isHidden = false + + let liveSession = AgentProvider.live.createLiveSession( + sessionId: chosen.id, + projectName: chosen.projectName + ) + session = liveSession + wireSession(liveSession, providerName: "Live: \(chosen.projectName)") + liveSession.start() + + terminalView?.textView.textStorage?.setAttributedString(NSAttributedString(string: "")) + terminalView?.appendStreamingText("connected to \(chosen.projectName)\n") + terminalView?.appendStreamingText("watching session \(chosen.id.prefix(8))...\n\n") + terminalView?.endStreaming() + + if !liveSession.history.isEmpty { + terminalView?.replayHistory(liveSession.history) + } + + if let terminal = terminalView { + popoverWindow?.makeFirstResponder(terminal.inputField) + } + } + + private func handleSessionPick(_ message: String) { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.lowercased() == "/sessions" { + showSessionPicker() + return + } + + // If picker is showing and user types a number, handle it + guard let num = Int(trimmed), num >= 1, num <= pendingSessions.count else { + if !pendingSessions.isEmpty { + showSessionPicker() + } + return + } + + connectToSession(pendingSessions[num - 1]) + } + @objc func copyLastResponseFromButton() { // Trigger the /copy slash command via the terminal view terminalView?.handleSlashCommandPublic("/copy") @@ -711,10 +889,8 @@ class WalkerCharacter { } walkStartPos = positionProgress - // Walk a fixed pixel distance (~200-325px) regardless of screen width. - let referenceWidth: CGFloat = 500.0 - let walkPixels = CGFloat.random(in: walkAmountRange) * referenceWidth - let walkAmount = currentTravelDistance > 0 ? walkPixels / currentTravelDistance : 0.3 + // Walk a fraction of the actual travel distance (full screen width) + let walkAmount = CGFloat.random(in: walkAmountRange) if goingRight { walkEndPos = min(walkStartPos + walkAmount, 1.0) } else { @@ -724,7 +900,7 @@ class WalkerCharacter { walkStartPixel = walkStartPos * currentTravelDistance walkEndPixel = walkEndPos * currentTravelDistance - let minSeparation: CGFloat = 0.12 + let minSeparation: CGFloat = 0.05 if let siblings = controller?.characters { for sibling in siblings where sibling !== self { let sibPos = sibling.positionProgress @@ -738,6 +914,9 @@ class WalkerCharacter { } } + // Schedule a possible jump during this walk + nextJumpTime = CACurrentMediaTime() + Double.random(in: 1.0...3.0) + updateFlip() queuePlayer.seek(to: .zero) queuePlayer.play() @@ -746,12 +925,35 @@ class WalkerCharacter { func enterPause() { isWalking = false isPaused = true + isJumping = false queuePlayer.pause() queuePlayer.seek(to: .zero) - let delay = Double.random(in: 5.0...12.0) + let delay = Double.random(in: 3.0...7.0) pauseEndTime = CACurrentMediaTime() + delay } + /// Returns the vertical offset for a jump (parabolic arc), or 0 if not jumping. + private func jumpOffset(at now: CFTimeInterval) -> CGFloat { + guard isJumping else { return 0 } + let t = (now - jumpStartTime) / jumpDuration + if t >= 1.0 { + isJumping = false + return 0 + } + // Parabola: peaks at t=0.5, returns to 0 at t=1 + return jumpHeight * 4.0 * CGFloat(t) * CGFloat(1.0 - t) + } + + private func maybeStartJump(at now: CFTimeInterval) { + guard isWalking, !isJumping, now >= nextJumpTime else { return } + // ~15% chance to jump each time we check + if Double.random(in: 0...1) < 0.15 { + isJumping = true + jumpStartTime = now + } + nextJumpTime = now + Double.random(in: 2.0...5.0) + } + func updateFlip() { CATransaction.begin() CATransaction.setDisableActions(true) @@ -843,9 +1045,12 @@ class WalkerCharacter { return } + // Maybe start a jump while walking + maybeStartJump(at: now) + let x = dockX + travelDistance * positionProgress + currentFlipCompensation let bottomPadding = displayHeight * 0.15 - let y = dockTopY - bottomPadding + yOffset + let y = dockTopY - bottomPadding + yOffset + jumpOffset(at: now) window.setFrameOrigin(NSPoint(x: x, y: y)) } diff --git a/README.md b/README.md index 7b996d8..a9a6707 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,13 @@ 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 +- **Live Session mode** — connect to a running Claude Code session and watch tool calls stream in real time +- Switch between Claude, Codex, Copilot, Gemini, and Live Session from the menubar - Four visual themes: Peach, Midnight, Cloud, Moss -- Slash commands: `/clear`, `/copy`, `/help` in the chat input +- Slash commands: `/clear`, `/copy`, `/sessions`, `/help` in the chat input - Copy last response button in the title bar - Thinking bubbles with playful phrases while your agent works +- Characters roam the full screen and occasionally jump - Sound effects on completion - First-run onboarding with a friendly welcome - Auto-updates via Sparkle @@ -33,9 +35,104 @@ Supports **Claude Code**, **OpenAI Codex**, **GitHub Copilot**, and **Google Gem - [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` -## building - -Open `lil-agents.xcodeproj` in Xcode and hit run. +## live session mode + +Live Session lets Bruce and Jazz observe a real Claude Code session — they'll show tool calls, file reads, and edits as they happen, and you can send notes back into the session. + +### setup + +**1. Copy the bridge hook:** + +```bash +mkdir -p ~/.claude/lil-agents +cp hooks/lil-agents-bridge.mjs ~/.claude/lil-agents/bridge.mjs +``` + +**2. Add hook entries to `~/.claude/settings.json`:** + +Add these to the `"hooks"` object in your Claude Code settings (create the `"hooks"` key if it doesn't exist): + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node ~/.claude/lil-agents/bridge.mjs" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node ~/.claude/lil-agents/bridge.mjs" + } + ] + } + ], + "Notification": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node ~/.claude/lil-agents/bridge.mjs" + } + ] + } + ] + } +} +``` + +If you already have hooks configured, merge these entries into the existing arrays. + +**3. Connect:** + +- Start any Claude Code session (terminal, VS Code, etc.) +- In the lil agents menubar: **Provider > Live Session** — you'll see your active sessions listed +- Click a session to connect, then click Bruce or Jazz to watch it live + +### how it works + +``` +Your Claude Code session + │ bridge hook fires on every tool use + ↓ +~/.claude/lil-agents/sessions/.jsonl (event stream) +~/.claude/lil-agents/sessions/.meta (session metadata) + │ Bruce/Jazz watch the file for changes + ↓ +Popover shows live tool calls, results, and notifications + │ you type a message in the popover + ↓ +~/.claude/lil-agents/inbox/.jsonl (queued message) + │ hook reads inbox on next tool use + ↓ +Injected as additionalContext into your Claude session +``` + +Each character can connect to a different session — put Bruce on one project and Jazz on another. + +## install from source + +```bash +git clone https://github.com/Orbasker/lil-agents.git +cd lil-agents +git checkout feat/live-session-bridge +xcodebuild -scheme LilAgents -configuration Release build +cp -R ~/Library/Developer/Xcode/DerivedData/lil-agents-*/Build/Products/Release/lil\ agents.app /Applications/ +open "/Applications/lil agents.app" +``` + +Requires Xcode (or Xcode Command Line Tools with full Xcode installed). On first launch you may need to right-click > Open since the app is not notarized. ## privacy diff --git a/hooks/lil-agents-bridge.mjs b/hooks/lil-agents-bridge.mjs new file mode 100644 index 0000000..96d8c98 --- /dev/null +++ b/hooks/lil-agents-bridge.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +// lil-agents bridge — Claude Code hook that broadcasts session events +// so Bruce & Jazz can observe and interact with active sessions. +// +// Install: add to ~/.claude/settings.json (see README) + +import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync, unlinkSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; + +const BASE_DIR = join(homedir(), '.claude', 'lil-agents'); +const SESSIONS_DIR = join(BASE_DIR, 'sessions'); +const INBOX_DIR = join(BASE_DIR, 'inbox'); + +mkdirSync(SESSIONS_DIR, { recursive: true }); +mkdirSync(INBOX_DIR, { recursive: true }); + +// Read hook input from stdin +let input = ''; +try { + input = readFileSync('/dev/stdin', 'utf-8'); +} catch { + process.exit(0); +} + +let data; +try { + data = JSON.parse(input); +} catch { + process.exit(0); +} + +const sessionId = data.session_id || process.env.CLAUDE_SESSION_ID || 'unknown'; + +// Infer hook type from data shape +let hookType = 'unknown'; +if (data.tool_name && data.tool_output !== undefined) { + hookType = 'PostToolUse'; +} else if (data.tool_name) { + hookType = 'PreToolUse'; +} else if (data.event) { + hookType = 'SessionStart'; +} else if (data.notification) { + hookType = 'Notification'; +} + +// Build event record +const event = { + ts: Date.now(), + hook: hookType, + session_id: sessionId, +}; + +if (data.tool_name) event.tool = data.tool_name; +if (data.tool_input) { + // Keep tool input concise + const ti = data.tool_input; + event.input = {}; + if (ti.file_path) event.input.file_path = ti.file_path; + if (ti.command) event.input.command = String(ti.command).slice(0, 200); + if (ti.pattern) event.input.pattern = ti.pattern; + if (ti.description) event.input.description = String(ti.description).slice(0, 200); +} + +if (hookType === 'PostToolUse' && data.tool_output) { + const out = typeof data.tool_output === 'string' + ? data.tool_output + : JSON.stringify(data.tool_output); + event.output = out.slice(0, 300); +} + +if (data.event) event.event = data.event; +if (data.notification) event.notification = String(data.notification).slice(0, 300); + +// Resolve cwd — prefer data, fall back to env, then process +const cwd = data.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd(); +event.cwd = cwd; + +// Append event to session file +const sessionFile = join(SESSIONS_DIR, `${sessionId}.jsonl`); +appendFileSync(sessionFile, JSON.stringify(event) + '\n'); + +// Update meta file +const metaFile = join(SESSIONS_DIR, `${sessionId}.meta`); +let meta = { + session_id: sessionId, + cwd, + last_event: Date.now(), +}; + +try { + const existing = JSON.parse(readFileSync(metaFile, 'utf-8')); + meta.started_at = existing.started_at; +} catch { + meta.started_at = Date.now(); +} + +if (hookType === 'SessionStart') { + meta.started_at = Date.now(); +} + +writeFileSync(metaFile, JSON.stringify(meta) + '\n'); + +// Check inbox for messages to inject into the session +const inboxFile = join(INBOX_DIR, `${sessionId}.jsonl`); +let additionalContext = ''; + +if (existsSync(inboxFile)) { + try { + const content = readFileSync(inboxFile, 'utf-8').trim(); + if (content) { + const lines = content.split('\n') + .map(l => { try { return JSON.parse(l); } catch { return null; } }) + .filter(Boolean); + + if (lines.length > 0) { + additionalContext = lines + .map(m => `[lil-agents note from ${m.from || 'buddy'}]: ${m.text}`) + .join('\n'); + unlinkSync(inboxFile); + } + } + } catch { /* ignore read errors */ } +} + +// Return response — inject inbox messages as additionalContext +if (additionalContext) { + process.stdout.write(JSON.stringify({ additionalContext })); +} diff --git a/lil-agents.xcodeproj/project.pbxproj b/lil-agents.xcodeproj/project.pbxproj index 6cdde8a..511808e 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 /* LiveSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000035 /* LiveSession.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 /* LiveSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveSession.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 /* LiveSession.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 /* LiveSession.swift in Sources */, A10000010000000000000023 /* TerminalView.swift in Sources */, A10000010000000000000024 /* WalkerCharacter.swift in Sources */, A10000010000000000000025 /* LilAgentsController.swift in Sources */,