diff --git a/.kanban/board.md b/.kanban/board.md index 641f1fcf..a3bea0ce 100644 --- a/.kanban/board.md +++ b/.kanban/board.md @@ -1,5 +1,5 @@ # Kanban Board - + ## Backlog @@ -38,6 +38,20 @@ ## Doing +### [T-011: Cherry-pick features từ reference projects](tasks/T-011-reference-sync-apr09.md) +> Replicate 7 features từ upstream, open-vibe-island, notchi — adapt theo kiến trúc mình. +- **priority**: high +- **effort**: L +#### Criteria +- [x] T-011a: Hook exec PID fix +- [x] T-011b: Structured tool status display +- [x] T-011c: PID liveness check +- [x] T-011d: Click session card → jump terminal +- [x] T-011e: Stale subagent cleanup +- [x] T-011f: Dynamic approval buttons +- [ ] ~~T-011g: Auto-scroll activity feed~~ (skipped — no expandable messages in our UI) +- [x] `swift build && swift test` passes on all changes + ## Done ### [T-006: Typed HookEvent thay rawJSON](tasks/T-006-typed-hook-events.md) diff --git a/.kanban/tasks/T-011-reference-sync-apr09.md b/.kanban/tasks/T-011-reference-sync-apr09.md new file mode 100644 index 00000000..6918d6d6 --- /dev/null +++ b/.kanban/tasks/T-011-reference-sync-apr09.md @@ -0,0 +1,176 @@ +# T-011: Cherry-pick features từ reference projects (Apr 2026) + +> Đọc, hiểu, và replicate các feature hay từ upstream CodeIsland, open-vibe-island, và notchi — adapt theo kiến trúc của mình (pure reducer, typed events, weak appState services). + +## Nguyên tắc + +- **KHÔNG copy-paste** — đọc code nguồn, hiểu intent, implement lại theo pattern của mình +- Follow pure reducer + SideEffect pattern +- Typed events, không raw JSON +- Services giữ weak appState, không callbacks +- Test mọi logic change qua reducer tests + +## Subtasks + +### T-011a: Hook exec PID fix (from upstream b18e5b9) +**Effort:** XS | **Priority:** high | **Status:** RESEARCHED + +**Vấn đề:** Hook script v3 chạy `"$BRIDGE" "$@"` rồi `exit $?`. Bash vẫn là parent → bridge gọi `getppid()` → nhận PID của bash (short-lived, ~ms) → `findCLIAncestorPid()` walk process tree nhưng bash đã exit → PID stale → mascot flicker mỗi ~2s giữa working/idle. + +**Root cause:** `getppid()` trong bridge trả PID bash thay vì CLI. Process tree walker cần parent còn sống để traverse. + +**Giải pháp:** `exec "$BRIDGE" "$@"` — `exec` replace bash process image bằng bridge binary. Bridge inherit bash's PID, trở thành direct child của CLI. `getppid()` trả đúng CLI PID. + +**Thay đổi cụ thể (2 dòng trong ConfigInstaller.swift):** +1. `hookScriptVersion: 3 → 4` (trigger re-install cho existing users) +2. Hook script body: `"$BRIDGE" "$@"\n exit $?` → `exec "$BRIDGE" "$@"` + +**Verification:** +- `swift build` passes +- Hook script file tại `~/.claude/hooks/codeisland-hook.sh` sẽ được overwrite khi app launch (version check) + +**Criteria:** +- [x] Hook script template dùng `exec` trước bridge binary +- [x] Existing installs sẽ được update khi ConfigInstaller chạy lại (version bump 3→4) +- [ ] `swift build` passes + +--- + +### T-011b: Structured tool status display (from upstream b995a58) +**Effort:** M | **Priority:** high | **Status:** RESEARCHED + +**Vấn đề:** toolDescription hiện tại derive quá đơn giản — lấy raw value, không phân biệt tool type. Bash hiện raw command (dài), Read chỉ filename, Grep chỉ pattern. + +**Giải pháp:** Switch theo toolName để derive context phù hợp: +- Bash → `description` field (preferred) hoặc first line of `command` (max 60 chars) +- Read → filename + `:offset` nếu có +- Edit/Write → filename +- Grep → pattern + ` in {dir}` +- Glob → pattern +- WebSearch → query +- WebFetch → domain only (URL.host) +- Agent/Task → description hoặc prompt prefix (40 chars) +- TodoWrite → "Updating tasks" +- Default → try common fields in order + +**Thay đổi:** +- `Models.swift` lines 92-107: Replace flat if-else chain với switch(toolName) { case ... } +- Giữ nguyên fallback chain cho events không có toolInput +- Không cần thay đổi views — đã hiển thị toolDescription rồi + +**Criteria:** +- [ ] toolDescription switch theo toolName cho 10+ tool types +- [ ] Bash prefer `description` over raw command +- [ ] Read show offset +- [ ] Grep show search dir +- [ ] WebSearch/WebFetch handled +- [ ] Tests cho toolDescription extraction +- [ ] `swift build && swift test` passes + +--- + +### T-011c: PID liveness check (from upstream b995a58) +**Effort:** S | **Priority:** high | **Status:** RESEARCHED + +**Current state:** +- DispatchSourceProcess monitors PID exit — but can miss events (system sleep, race) +- `onSessionExpired` calls `removeSession()` — removes entirely instead of resetting to idle +- Cleanup loop at 60s — orphan check + zombie PID check + dead process removal +- No explicit `kill(pid, 0)` liveness check for monitored sessions +- No stuck detection (sessions without monitor stuck in running forever) + +**Changes needed:** +1. **cleanupIdleSessions()** — add explicit liveness check for monitored PIDs: + - `kill(pid, 0) != 0 && errno == ESRCH` → stop monitor, reset to idle +2. **Stuck detection** — for unmonitored sessions: + - No tool + no monitor: 60s → idle + - Has tool + no monitor: 180s → idle + - (Skip monitored sessions — trust the monitor + liveness check above) +3. **Reduce cleanup interval** from 60s to 30s +4. **onSessionExpired** — reset to idle instead of remove (give time for reconnect) + +**Criteria:** +- [ ] `kill(pid, 0)` liveness check for monitored sessions in cleanup +- [ ] Stuck detection with tiered thresholds +- [ ] Cleanup interval reduced to 30s +- [ ] Process exit resets to idle, not removes +- [ ] `swift build && swift test` passes + +--- + +### T-011d: Click session card → jump terminal (from upstream 668b889) +**Effort:** S | **Priority:** medium + +**Vấn đề:** Phải click arrow button nhỏ để jump terminal. UX kém. + +**Giải pháp:** Wrap entire session card trong Button. Remove TerminalJumpButton arrow. Keep terminal icon as badge. + +**Files cần đọc:** +- Upstream: `SessionListView.swift` hoặc session card view — xem Button wrap +- Ours: `Sources/CodeIsland/SessionListView.swift`, related views + +**Criteria:** +- [ ] Entire session card clickable → jump to terminal +- [ ] Button style (not onTapGesture — NSPanel issue) +- [ ] TerminalJumpButton arrow removed, terminal badge giữ lại +- [ ] Không break existing card interactions (approve, question) +- [ ] `swift build` passes + +--- + +### T-011e: Stale subagent cleanup (from open-vibe-island a9229c7, 74e21ce) +**Effort:** S | **Priority:** medium + +**Vấn đề:** Khi SubagentStop event bị miss, subagent indicators mắc kẹt forever. + +**Giải pháp:** Timeout-based cleanup + turn-end detection. Khi parent nhận prompt mới → clear stale subagent state. + +**Files cần đọc:** +- open-vibe-island: tìm subagent cleanup logic +- Ours: `Sources/CodeIslandCore/SessionSnapshot.swift` — SubagentState +- Ours: `Sources/CodeIslandCore/Models.swift` — subagent fields + +**Criteria:** +- [ ] Subagent state cleanup khi parent nhận Prompt event (new turn) +- [ ] Timeout (60s?) cho subagents không có activity +- [ ] Cleanup logic trong reducer (pure function) +- [ ] Tests cho stale subagent scenarios +- [ ] `swift build && swift test` passes + +--- + +### T-011f: Dynamic approval buttons (from open-vibe-island da2b129, e5e84fa) +**Effort:** M | **Priority:** medium + +**Vấn đề:** Approval buttons hardcode "Allow"/"Deny". Claude Code gửi actual options trong event. + +**Giải pháp:** Parse permission_suggestions từ hook event, hiển thị actual button labels. + +**Files cần đọc:** +- open-vibe-island: tìm dynamic approval button logic +- Ours: `Sources/CodeIsland/ApprovalBarView.swift` +- Ours: `Sources/CodeIslandCore/Models.swift` — HookEvent fields + +**Criteria:** +- [ ] Parse permission options từ hook event +- [ ] ApprovalBarView render dynamic buttons +- [ ] Fallback về Allow/Deny nếu không có options +- [ ] `swift build` passes + +--- + +### T-011g: Auto-scroll activity feed (from notchi 265b2ce) +**Effort:** XS | **Priority:** low + +**Vấn đề:** Khi expand/collapse assistant message, scroll position không update. + +**Giải pháp:** ScrollViewReader + scrollTo khi expand/collapse state thay đổi. + +**Files cần đọc:** +- notchi: `ExpandedPanelView.swift` — xem auto-scroll logic +- Ours: views hiển thị activity/chat feed + +**Criteria:** +- [ ] Auto-scroll to bottom/expanded item khi expand +- [ ] Không scroll khi user đang manually scroll up +- [ ] `swift build` passes diff --git a/CLAUDE.md b/CLAUDE.md index 43a2a3dc..40ee8013 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ Models and business logic. Zero UI imports. All types are `Sendable` + `Codable` | File | Purpose | |------|---------| -| `Models.swift` | `AgentStatus`, `HookEvent` (typed — `EventMetadata` + typed fields, no rawJSON), `SubagentState`, `ToolHistoryEntry`, `ChatMessage`, `HookResponse`, `QuestionPayload` | +| `Models.swift` | `AgentStatus`, `HookEvent` (typed — `EventMetadata` + typed fields, no rawJSON), `SubagentState`, `ChatMessage`, `HookResponse`, `QuestionPayload` | | `SessionSnapshot.swift` | `SessionSnapshot` (Sendable, Codable), `reduceEvent()` pure reducer, `extractMetadata()`, `SideEffect` enum, `TokenUsage`, `deriveSessionSummary()` | | `MascotState.swift` | `MascotTask`, `MascotEmotion`, `MascotState` — sprite animation state model (ported from notchi) | | `EmotionState.swift` | `EmotionState` — emotion scoring + decay (happy/sad/sob thresholds, 60s decay cycle) | @@ -72,7 +72,8 @@ This is the core architectural pattern. All session state transitions go through - `metadata: EventMetadata` — shared fields (cwd, model, terminal info, etc.) - Event-specific: `prompt`, `lastAssistantMessage`, `errorDetails`, `isInterrupt`, `agentType`, `newCwd`, `question`, etc. - `askUserPayload: QuestionPayload?` — pre-parsed from AskUserQuestion tool_input -- `toolDescription: String?` — derived from tool_input at parse time +- `toolDescription: String?` — derived per tool type at parse time (Bash→description, Read→file:offset, Grep→pattern+dir, etc.) +- `permissionSuggestions: [[String: Any]]?` — raw permission update suggestions from Claude Code ### Permission Auto-Approve @@ -107,9 +108,15 @@ Last synced commit: `c016c4a` (v1.0.9) | 2026-04-07 | v1.0.7 | `f00f2e7` | Cherry-pick bugfixes | half-close race, startup race, auto-approve tools, CLI version compat, compact bar display | | 2026-04-07 | v1.0.8 | `447ed88` | Cherry-pick feature | Horizontal drag (always-on, no setting toggle) | | 2026-04-07 | v1.0.9 | `c016c4a` | Cherry-pick bugfix | Ghostty exact matching — avoid misidentifying libghostty apps | +| 2026-04-09 | post-v1.0.15 | `b995a58` | Selective cherry-pick | Structured tool status display, PID liveness check 30s, stuck detection | +| 2026-04-09 | post-v1.0.15 | `b18e5b9` | Cherry-pick bugfix | Hook exec PID fix — `exec` replaces bash for correct getppid() | +| 2026-04-09 | post-v1.0.15 | `668b889` | Cherry-pick UX | Click entire session card to jump terminal (Button wrap) | -Unsynced from v1.0.7: global shortcuts, tool status in compact bar, in-app auto-update, CI/CD pipeline. +Also ported from **open-vibe-island**: stale subagent cleanup (3min timeout + prompt clear), dynamic permission_suggestions parsing. + +Unsynced from v1.0.7: global shortcuts, in-app auto-update, CI/CD pipeline. Unsynced from v1.0.8: Copilot CLI support (not needed — Claude Code only). +Unsynced from post-v1.0.15: menu bar icon, MorphText animation, BlurFade transition, diagnostics exporter, custom sound per event, StatusItemController KVO refactor, Warp terminal fix, Ghostty tmux focus, notarization/DMG build. We only support Claude Code (no Codex/OpenCode). Cherry-pick relevant changes instead of full merge. diff --git a/Sources/CodeIsland/AppDelegate.swift b/Sources/CodeIsland/AppDelegate.swift index ec91d9b1..c74df3e7 100644 --- a/Sources/CodeIsland/AppDelegate.swift +++ b/Sources/CodeIsland/AppDelegate.swift @@ -109,7 +109,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { hookRecoveryTask?.cancel() diagnostics.stop() - appState.saveSessions() hookServer?.stop() appState.stopSessionDiscovery() } diff --git a/Sources/CodeIsland/AppState.swift b/Sources/CodeIsland/AppState.swift index 585910d7..7bc54bb8 100644 --- a/Sources/CodeIsland/AppState.swift +++ b/Sources/CodeIsland/AppState.swift @@ -28,14 +28,10 @@ final class AppState { set { completionQueue.completionHasBeenEntered = newValue } } - private var maxHistory: Int { SettingsManager.shared.maxToolHistory } private var cleanupTask: Task? let processMonitor = ProcessMonitorService() - let discoveryService = SessionDiscoveryService() let completionQueue = CompletionQueueService() let requestQueue = RequestQueueService() - private var saveTask: Task? - private var modelReadAttempted: Set = [] /// When true, side effects (sound, completion animation) are suppressed. /// Used during startup restoration to avoid noisy replays. private var suppressSideEffects = false @@ -80,12 +76,12 @@ final class AppState { sessions[sessionId] = SessionSnapshot() } extractMetadata(into: &sessions, sessionId: sessionId, event: event) - processMonitor.tryMonitor( - sessionId: sessionId, - cliPid: sessions[sessionId]?.cliPid, - cwd: sessions[sessionId]?.cwd, - onPidFound: { [weak self] sid, pid in self?.sessions[sid]?.cliPid = pid } - ) + processMonitor.tryMonitor(sessionId: sessionId, cliPid: sessions[sessionId]?.cliPid) + // Record process creation time for PID-reuse detection + if sessions[sessionId]?.processStartTime == nil, + let pid = sessions[sessionId]?.cliPid, pid > 0 { + sessions[sessionId]?.processStartTime = ProcessScanner.startTime(for: pid) + } // Same process, new session_id → remove the stale entry if isNew, let pid = sessions[sessionId]?.cliPid, pid > 0 { for (key, _) in sessions where key != sessionId && sessions[key]?.cliPid == pid { @@ -123,11 +119,27 @@ final class AppState { sessions[sessionId] != nil } + /// Reset a session to idle state, clearing tool info. + /// Centralizes the pattern used by cleanup, process exit, and deny. + private func resetToIdle(_ sessionId: String) { + guard sessions[sessionId]?.status != .idle else { return } + sessions[sessionId]?.status = .idle + sessions[sessionId]?.currentTool = nil + sessions[sessionId]?.toolDescription = nil + } + private func startCleanupLoop() { cleanupTask = Task { [weak self] in + var tick = 0 while !Task.isCancelled { - try? await Task.sleep(for: .seconds(60)) - self?.cleanupIdleSessions() + try? await Task.sleep(for: .seconds(5)) + tick += 1 + // Light pass every 5s: liveness checks + stuck detection + self?.cleanupLight() + // Heavy pass every 30s: full process scan, orphan cleanup, token refresh + if tick % 6 == 0 { + self?.cleanupHeavy() + } } } } @@ -145,12 +157,49 @@ final class AppState { } } - private func cleanupIdleSessions() { - // Expire recently-exited session markers (30s is plenty to absorb late socket events) + /// Light cleanup (every 5s): liveness checks on monitored PIDs + stuck session reset. + /// Only uses `kill(pid, 0)` — no full process table scan. + private func cleanupLight() { + // Expire recently-exited session markers let cutoff = Date().addingTimeInterval(-30) recentlyExitedSessions = recentlyExitedSessions.filter { $0.value > cutoff } - // Refresh transcript file sizes and token usage + var mutated = false + + for (key, session) in sessions { + if processMonitor.isMonitoring(key) { + // Check monitored PID liveness (DispatchSourceProcess can miss exits) + if let pid = processMonitor.pid(for: key), + kill(pid, 0) != 0, errno == ESRCH { + processMonitor.stop(sessionId: key) + resetToIdle(key) + mutated = true + continue + } + // Stuck monitored: alive process, no active tool for 120s + if session.isStuckCandidate, + session.currentTool == nil, + -session.lastActivity.timeIntervalSinceNow > 120 { + resetToIdle(key) + mutated = true + } + } else { + // Stuck unmonitored: no events for threshold period + if session.isStuckCandidate { + let threshold: TimeInterval = session.currentTool != nil ? 180 : 60 + if -session.lastActivity.timeIntervalSinceNow > threshold { + resetToIdle(key) + mutated = true + } + } + } + } + + if mutated { refreshDerivedState() } + } + + /// Heavy cleanup (every 30s): full process scan, orphan kill, PID-reuse detection, token refresh. + private func cleanupHeavy() { refreshTokenUsage() for (sessionId, session) in sessions { guard let path = session.transcriptPath, !path.isEmpty else { continue } @@ -160,60 +209,58 @@ final class AppState { } } - // Cache running Claude PIDs for this cleanup cycle (avoids repeated scans) let claudePids = Set(ProcessScanner.findClaudePids()) - // 1. Kill orphaned Claude processes (terminal closed but process survived) - // Collect first to avoid mutating during iteration + // Kill orphaned processes (terminal closed, process survived with ppid <= 1) var orphaned: [(String, pid_t)] = [] - for (sessionId, session) in sessions { + for (sessionId, _) in sessions { guard let pid = processMonitor.pid(for: sessionId) else { continue } var info = proc_bsdinfo() let ret = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, Int32(MemoryLayout.size)) if ret > 0 && info.pbi_ppid <= 1 { orphaned.append((sessionId, pid)) } - _ = session // silence unused warning } for (sessionId, pid) in orphaned { kill(pid, SIGTERM) removeSession(sessionId) } - // 2. Remove zombie sessions — detached monitors watching reused PIDs + // Check monitored PIDs against Claude process list + PID-reuse detection + var deadMonitors: [String] = [] for (key, _) in sessions { guard processMonitor.isMonitoring(key) else { continue } guard let pid = processMonitor.pid(for: key) else { continue } if !claudePids.contains(pid) { - // Monitor is watching a PID that's no longer Claude (reused by another process) - processMonitor.stop(sessionId: key) - removeSession(key) + deadMonitors.append(key) + } else if let savedStart = sessions[key]?.processStartTime, + let currentStart = ProcessScanner.startTime(for: pid), + abs(savedStart.timeIntervalSince(currentStart)) > 2 { + // Same PID, different process instance (OS reused PID) + deadMonitors.append(key) } } + for key in deadMonitors { + processMonitor.stop(sessionId: key) + resetToIdle(key) + } - // 3. Remove sessions whose process is dead (any status — prevents phantom counts) + // Unmonitored sessions: try to attach monitor or remove if dead + let deadMonitorKeys = Set(deadMonitors) for (key, session) in sessions { - if processMonitor.isMonitoring(key) { continue } - - if let pid = session.cliPid, pid > 0 { - if kill(pid, 0) != 0 { - // PID dead — remove after brief grace (10s) - let inactiveSeconds = -session.lastActivity.timeIntervalSinceNow - if inactiveSeconds > 10 { removeSession(key) } - } else if !claudePids.contains(pid) { - // PID alive but NOT Claude — PID was reused by another process - removeSession(key) - } else { - // PID alive and IS Claude but not monitored — reattach - processMonitor.monitor(sessionId: key, pid: pid) - } + if processMonitor.isMonitoring(key) || deadMonitorKeys.contains(key) { continue } + guard let pid = session.cliPid, pid > 0 else { + removeSession(key) + continue + } + if kill(pid, 0) != 0 || !claudePids.contains(pid) { + removeSession(key) } else { - // No PID at all — safety net: remove after 5 min inactive - let inactiveMinutes = Int(-session.lastActivity.timeIntervalSinceNow / 60) - if inactiveMinutes >= 5 { removeSession(key) } + processMonitor.monitor(sessionId: key, pid: pid) } } - // refreshDerivedState is called inside removeSession for each removal + + refreshDerivedState() } /// Remove a session, clean up its monitor, and resume any pending continuations. @@ -227,6 +274,7 @@ final class AppState { drainPermissions(forSession: sessionId) drainQuestions(forSession: sessionId) clearAutoApproveState(forSession: sessionId) + SoundManager.shared.clearCooldown(for: sessionId) if surface.sessionId == sessionId { showNextPending() @@ -295,6 +343,8 @@ final class AppState { private(set) var primarySource: String = "claude" private(set) var activeSessionCount: Int = 0 private(set) var totalSessionCount: Int = 0 + /// Sorted session IDs — cached to avoid re-sorting in view body evaluations + private(set) var sortedSessionIds: [String] = [] var currentTool: String? { guard let id = activeSessionId, let s = sessions[id] else { return nil } @@ -326,6 +376,8 @@ final class AppState { if primarySource != summary.primarySource { primarySource = summary.primarySource } if activeSessionCount != summary.activeSessionCount { activeSessionCount = summary.activeSessionCount } if totalSessionCount != summary.totalSessionCount { totalSessionCount = summary.totalSessionCount } + let newSorted = sessions.keys.sorted() + if sortedSessionIds != newSorted { sortedSessionIds = newSorted } } private func refreshProviderTitle(for trackedSessionId: String, providerSessionId: String? = nil) { @@ -383,7 +435,7 @@ final class AppState { let wasWaiting = sessions[sessionId]?.status == .waitingApproval || sessions[sessionId]?.status == .waitingQuestion - let effects = reduceEvent(sessions: &sessions, event: event, trackingKey: sessionId, maxHistory: maxHistory) + let effects = reduceEvent(sessions: &sessions, event: event, trackingKey: sessionId) // Debug: log subagent state after reduce if let snap = sessions[sessionId], !snap.subagents.isEmpty { @@ -397,20 +449,6 @@ final class AppState { lastInputSessionId = sessionId } - // Model transcript read: done AFTER reduceEvent so extractMetadata has filled in cwd - // Use raw sessionId for transcript lookup (file path uses original session ID) - let rawSessionId = event.sessionId ?? "default" - if sessions[sessionId]?.model == nil && !modelReadAttempted.contains(sessionId) { - modelReadAttempted.insert(sessionId) - let cwd = sessions[sessionId]?.cwd - if let model = SessionDiscoveryService.readModelFromTranscript(sessionId: rawSessionId, cwd: cwd) { - sessions[sessionId]?.model = model - } - } - - // Messages are managed by the reducer (UserPromptSubmit → user msg, Stop → assistant msg). - // Transcript reads are only used at discovery/restore time for initial population. - // If session was waiting but received an activity event, the question/permission // was answered externally (e.g. user replied in terminal). Clear pending items. if wasWaiting { @@ -449,33 +487,31 @@ final class AppState { refreshProviderTitle(for: sessionId) } - // Handle the "else if activeSessionId == sessionId → mostActive" edge case - // (reducer can't check activeSessionId since it's AppState-local) + // When a session goes idle, switch to the most active remaining session if sessions[sessionId]?.status == .idle && activeSessionId == sessionId { - if event.eventName != "Stop" { - activeSessionId = mostActiveSessionId() - } + activeSessionId = mostActiveSessionId() } - scheduleSave() startRotationIfNeeded() refreshDerivedState() + + // Auto-collapse session list when all sessions go idle + if activeSessionCount == 0, case .sessionList = surface { + withAnimation(NotchAnimation.close) { + surface = .collapsed + } + completionQueue.flushIfNeeded() + } } private func executeEffect(_ effect: SideEffect, sessionId: String) { switch effect { case .playSound(let eventName): guard !suppressSideEffects else { break } - SoundManager.shared.handleEvent(eventName) + let interactive = sessions[sessionId]?.interactive ?? true + SoundManager.shared.handleEvent(eventName, sessionId: sessionId, interactive: interactive) case .tryMonitorSession(let sid): - processMonitor.tryMonitor( - sessionId: sid, - cliPid: sessions[sid]?.cliPid, - cwd: sessions[sid]?.cwd, - onPidFound: { [weak self] sessionId, pid in - self?.sessions[sessionId]?.cliPid = pid - } - ) + processMonitor.tryMonitor(sessionId: sid, cliPid: sessions[sid]?.cliPid) case .stopMonitor(let sid): processMonitor.stop(sessionId: sid) case .removeSession(let sid): @@ -510,12 +546,7 @@ final class AppState { func denyPermission() { guard let sessionId = requestQueue.deny() else { return } - if var snap = sessions[sessionId] { - snap.status = .idle - snap.currentTool = nil - snap.toolDescription = nil - sessions[sessionId] = snap - } + resetToIdle(sessionId) if activeSessionId == sessionId { activeSessionId = mostActiveSessionId() } @@ -548,12 +579,7 @@ final class AppState { sessions[sessionId] = SessionSnapshot() } extractMetadata(into: &sessions, sessionId: sessionId, event: event) - processMonitor.tryMonitor( - sessionId: sessionId, - cliPid: sessions[sessionId]?.cliPid, - cwd: sessions[sessionId]?.cwd, - onPidFound: { [weak self] sid, pid in self?.sessions[sid]?.cliPid = pid } - ) + processMonitor.tryMonitor(sessionId: sessionId, cliPid: sessions[sessionId]?.cliPid) let payload = event.askUserPayload ?? QuestionPayload(question: "Question", options: nil) @@ -617,8 +643,20 @@ final class AppState { case .approvalCard, .questionCard: surface = next case .collapsed: - if case .approvalCard = surface { surface = .collapsed } - else if case .questionCard = surface { surface = .collapsed } + // No more permissions/questions — drain queued completions before collapsing. + // Only drain when transitioning FROM an interactive card (approval/question). + // If surface is .completionCard or .sessionList, we don't interfere — those + // surfaces have their own dismiss lifecycle. + let wasInteractive: Bool = { + if case .approvalCard = surface { return true } + if case .questionCard = surface { return true } + return false + }() + if wasInteractive { + completionQueue.flushIfNeeded() + if case .completionCard = surface { return } // flush showed a completion + } + surface = .collapsed default: break } @@ -640,72 +678,22 @@ final class AppState { return (bestNonIdle ?? bestAny)?.key } - // MARK: - Session Discovery (FSEventStream + process scan) - - /// Start continuous monitoring: initial process scan + FSEventStream on ~/.claude/projects/ - // MARK: - Session Persistence + // MARK: - Session Discovery - private func scheduleSave() { - saveTask?.cancel() - saveTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(2)) - guard !Task.isCancelled else { return } - self?.saveSessions() - } - } - - func saveSessions() { - SessionPersistence.save(sessions) - } - - private func restoreSessions() { - let loaded = SessionPersistence.load() - let cutoff = Date().addingTimeInterval(-30 * 60) // 30 minutes - let claudePids = Set(ProcessScanner.findClaudePids()) - - // Filter by cutoff and valid source, then deduplicate by PID. - var bestByPid: [Int32: (sessionId: String, snapshot: SessionSnapshot)] = [:] - var noPidEntries: [(sessionId: String, snapshot: SessionSnapshot)] = [] - for (sessionId, snapshot) in loaded where snapshot.lastActivity > cutoff { - guard SessionSnapshot.normalizedSupportedSource(snapshot.source) != nil else { continue } - if let pid = snapshot.cliPid, pid > 0 { - if let existing = bestByPid[pid] { - if snapshot.lastActivity > existing.snapshot.lastActivity { - bestByPid[pid] = (sessionId, snapshot) - } - } else { - bestByPid[pid] = (sessionId, snapshot) - } - } else { - noPidEntries.append((sessionId, snapshot)) - } - } - let deduped = Array(bestByPid.values) + noPidEntries - - for (sessionId, snapshot) in deduped { - guard sessions[sessionId] == nil else { continue } - guard SessionSnapshot.normalizedSupportedSource(snapshot.source) != nil else { continue } - - // Skip sessions whose process is dead — don't show zombies at startup - if let pid = snapshot.cliPid, pid > 0 { - guard claudePids.contains(pid) else { continue } - sessions[sessionId] = snapshot - refreshProviderTitle(for: sessionId) - processMonitor.monitor(sessionId: sessionId, pid: pid) - } else { - // No PID — only restore if a matching Claude process is found by CWD - guard let cwd = snapshot.cwd, - let pid = ProcessMonitorService.findPidForCwd(cwd) else { continue } - var restored = snapshot - restored.cliPid = pid - sessions[sessionId] = restored - refreshProviderTitle(for: sessionId) - processMonitor.monitor(sessionId: sessionId, pid: pid) - } + func startSessionDiscovery() { + startCleanupLoop() + setupServices() + processMonitor.onSessionExpired = { [weak self] sessionId, exitTime in + guard let self, let session = self.sessions[sessionId] else { return } + if session.lastActivity > exitTime { return } + self.resetToIdle(sessionId) + self.drainPermissions(forSession: sessionId) + self.drainQuestions(forSession: sessionId) + self.refreshDerivedState() } - SessionPersistence.clear() + processMonitor.appState = self - // Replay events logged while app was not running — suppress sounds/animations + // Replay events logged while app was not running suppressSideEffects = true for eventData in EventLog.readAndClear() { if let event = HookEvent(from: eventData) { @@ -715,110 +703,12 @@ final class AppState { suppressSideEffects = false if activeSessionId == nil { - activeSessionId = sessions.keys.sorted().first - } - refreshDerivedState() - } - - func startSessionDiscovery() { - startCleanupLoop() - setupServices() - processMonitor.onSessionExpired = { [weak self] sessionId, exitTime in - guard let self, let session = self.sessions[sessionId] else { return } - if session.lastActivity > exitTime { return } // fresh activity during grace - self.removeSession(sessionId) - } - // Restore persisted sessions before process scan (deduped by scan) - restoreSessions() - - discoveryService.onDiscovered = { [weak self] in self?.integrateDiscovered($0) } - - // Initial scan for already-running Claude sessions - Task.detached { - let claudeSessions = SessionDiscoveryService.findActiveClaudeSessions() - await MainActor.run { [weak self] in - self?.integrateDiscovered(claudeSessions) - } - } - // Start watching ~/.claude/projects/ for new session files - discoveryService.startWatching() - } - - /// Merge discovered sessions into current state (skip already-known ones) - private func integrateDiscovered(_ discovered: [DiscoveredSession]) { - var didAdd = false - for info in discovered { - // Session already known — try to attach PID monitor if missing - if sessions[info.sessionId] != nil { - if !processMonitor.isMonitoring(info.sessionId), let pid = info.pid { - processMonitor.monitor(sessionId: info.sessionId, pid: pid) - // Don't override status — process stays alive while waiting for user input. - // Hook events (Stop/UserPromptSubmit) are the source of truth for status. - } - refreshProviderTitle(for: info.sessionId, providerSessionId: info.sessionId) - continue - } - - // Dedup: if a hook-created session already exists with same source + cwd + pid, - // skip the discovered one to avoid duplicate entries (e.g. Codex hooks vs - // file-based discovery produce different session IDs for the same process). - // Only reject match when both PIDs are alive and different — stale PIDs - // from restored sessions should not prevent dedup. - let duplicateKey = sessions.first(where: { (_, existing) in - guard existing.source == info.source, - existing.cwd != nil, existing.cwd == info.cwd else { return false } - if let discoveredPid = info.pid, let existingPid = existing.cliPid, - discoveredPid != existingPid { - // Only reject if the existing PID is still alive (not stale from persistence) - return kill(existingPid, 0) != 0 - } - return true - })?.key - - if let existingKey = duplicateKey { - // Attach PID monitor and update stale cliPid on the existing session - if let pid = info.pid { - if sessions[existingKey]?.cliPid != pid { - sessions[existingKey]?.cliPid = pid - } - if !processMonitor.isMonitoring(existingKey) { - processMonitor.monitor(sessionId: existingKey, pid: pid) - } - } - refreshProviderTitle(for: existingKey, providerSessionId: info.sessionId) - continue - } - - var session = SessionSnapshot(startTime: info.modifiedAt) - session.cwd = info.cwd - session.model = info.model - session.ttyPath = info.tty - session.termBundleId = info.termBundleId - session.recentMessages = info.recentMessages - session.source = info.source - session.cliPid = info.pid - session.providerSessionId = info.source == "claude" ? info.sessionId : nil - if let last = info.recentMessages.last(where: { $0.isUser }) { - session.lastUserPrompt = last.text - } - if let last = info.recentMessages.last(where: { !$0.isUser }) { - session.lastAssistantMessage = last.text - } - sessions[info.sessionId] = session - refreshProviderTitle(for: info.sessionId, providerSessionId: info.sessionId) - if let pid = info.pid { - processMonitor.monitor(sessionId: info.sessionId, pid: pid) - } - didAdd = true - } - if didAdd && activeSessionId == nil { - activeSessionId = sessions.keys.sorted().first + activeSessionId = sortedSessionIds.first } refreshDerivedState() } func stopSessionDiscovery() { - discoveryService.stopWatching() processMonitor.stopAll() } @@ -826,8 +716,6 @@ final class AppState { MainActor.assumeIsolated { rotationTask?.cancel() cleanupTask?.cancel() - saveTask?.cancel() - discoveryService.stopWatching() processMonitor.stopAll() } } diff --git a/Sources/CodeIsland/CompletionQueueService.swift b/Sources/CodeIsland/CompletionQueueService.swift index 5644350f..11a85660 100644 --- a/Sources/CodeIsland/CompletionQueueService.swift +++ b/Sources/CodeIsland/CompletionQueueService.swift @@ -57,7 +57,7 @@ final class CompletionQueueService { private func shouldSuppressAppLevel(for sessionId: String) -> Bool { guard UserDefaults.standard.bool(forKey: SettingsKey.smartSuppress) else { return false } guard let session = appState?.sessions[sessionId], - (session.termApp != nil || session.termBundleId != nil) else { return false } + session.hasTerminalInfo else { return false } return TerminalVisibilityDetector.isTerminalFrontmostForSession(session) } diff --git a/Sources/CodeIsland/ConfigInstaller.swift b/Sources/CodeIsland/ConfigInstaller.swift index 6fc798d3..87a388d0 100644 --- a/Sources/CodeIsland/ConfigInstaller.swift +++ b/Sources/CodeIsland/ConfigInstaller.swift @@ -61,7 +61,7 @@ struct ConfigInstaller { ] /// Hook script version — bump this when the script template changes - private static let hookScriptVersion = 3 + private static let hookScriptVersion = 4 /// Hook script for Claude Code (dispatcher: bridge binary → nc fallback) private static let hookScript = """ @@ -69,8 +69,7 @@ struct ConfigInstaller { # CodeIsland hook v\(hookScriptVersion) — native bridge with shell fallback BRIDGE="$HOME/.claude/hooks/codeisland-bridge" if [ -x "$BRIDGE" ]; then - "$BRIDGE" "$@" - exit $? + exec "$BRIDGE" "$@" fi # Fallback: original shell approach (no binary installed yet) SOCK="/tmp/codeisland-$(id -u).sock" diff --git a/Sources/CodeIsland/DebugHarness.swift b/Sources/CodeIsland/DebugHarness.swift index c4e51bf4..a029a0bb 100644 --- a/Sources/CodeIsland/DebugHarness.swift +++ b/Sources/CodeIsland/DebugHarness.swift @@ -74,8 +74,6 @@ enum DebugHarness { s.toolDescription = "src/components/App.tsx" s.lastUserPrompt = "Fix the login button styling" s.addRecentMessage(ChatMessage(isUser: true, text: "Fix the login button styling")) - s.recordTool("Read", description: "package.json", success: true, agentType: nil, maxHistory: 20) - s.recordTool("Grep", description: "className.*login", success: true, agentType: nil, maxHistory: 20) s.termApp = "Ghostty" state.sessions["preview-working"] = s @@ -134,10 +132,6 @@ enum DebugHarness { s.lastAssistantMessage = "Done. Added the --verbose flag to the CLI parser with short alias -v. It enables detailed logging output throughout the pipeline." s.addRecentMessage(ChatMessage(isUser: true, text: "Add --verbose flag")) s.addRecentMessage(ChatMessage(isUser: false, text: "Done. Added the --verbose flag to the CLI parser with short alias -v.")) - s.recordTool("Read", description: "src/cli.rs", success: true, agentType: nil, maxHistory: 20) - s.recordTool("Edit", description: "src/cli.rs", success: true, agentType: nil, maxHistory: 20) - s.recordTool("Edit", description: "src/logger.rs", success: true, agentType: nil, maxHistory: 20) - s.recordTool("Bash", description: "cargo test", success: true, agentType: nil, maxHistory: 20) s.termApp = "Ghostty" state.sessions["preview-completion"] = s @@ -200,11 +194,6 @@ enum DebugHarness { s1.subagents["agent-3"] = SubagentState(agentId: "agent-3", agentType: "Explore") // Mark one as completed s1.subagents["agent-3"]?.status = .idle - s1.recordTool("Bash", description: "find . -name '*.ts'", success: true, agentType: nil, maxHistory: 20) - s1.recordTool("Read", description: "tsconfig.json", success: true, agentType: nil, maxHistory: 20) - s1.recordTool("Edit", description: "tsconfig.json", success: true, agentType: nil, maxHistory: 20) - s1.recordTool("Bash", description: "tsc --noEmit", success: false, agentType: nil, maxHistory: 20) - s1.recordTool("Edit", description: "src/index.ts", success: true, agentType: "general-purpose", maxHistory: 20) s1.termApp = "Ghostty" // Claude session processing @@ -246,8 +235,6 @@ enum DebugHarness { s.lastUserPrompt = "Refactor the networking layer" s.addRecentMessage(ChatMessage(isUser: true, text: "Refactor the networking layer")) s.addRecentMessage(ChatMessage(isUser: false, text: "I'll start by reading the current implementation...")) - s.recordTool("Read", description: "src/Network.swift", success: true, agentType: nil, maxHistory: 20) - s.recordTool("Grep", description: "URLSession", success: true, agentType: nil, maxHistory: 20) s.subagents["agent-1"] = SubagentState(agentId: "agent-1", agentType: "Explore") s.termApp = "Ghostty" state.sessions["preview-claude"] = s @@ -295,9 +282,6 @@ enum DebugHarness { s.currentTool = tools[i % tools.count] s.toolDescription = "src/module\(i).swift" } - for j in 0..<(i % 5) { - s.recordTool(tools[j % tools.count], description: "file\(j).ts", success: j % 4 != 0, agentType: nil, maxHistory: 20) - } if i % 7 == 0 { s.subagents["agent-\(i)-1"] = SubagentState(agentId: "agent-\(i)-1", agentType: "general-purpose") } diff --git a/Sources/CodeIsland/HookServer.swift b/Sources/CodeIsland/HookServer.swift index 39b7f7d7..248f4bff 100644 --- a/Sources/CodeIsland/HookServer.swift +++ b/Sources/CodeIsland/HookServer.swift @@ -23,7 +23,12 @@ class HookServer { } func start() { - // Clean up stale socket + // Probe existing socket before unlinking — avoid stealing from active listener + if probeSocket(at: HookServer.socketPath) { + log.warning("Another instance is actively listening on \(HookServer.socketPath), skipping start") + return + } + // Clean up stale socket (probe confirmed no active listener) unlink(HookServer.socketPath) let params = NWParameters() @@ -210,4 +215,38 @@ class HookServer { connection.cancel() }) } + + /// Probe whether a socket file has an active listener. + /// Returns true if another process is listening (don't steal it). + private func probeSocket(at path: String) -> Bool { + // No socket file → nothing to steal + var statBuf = stat() + guard stat(path, &statBuf) == 0, (statBuf.st_mode & S_IFMT) == S_IFSOCK else { + return false + } + // Try to connect — if it succeeds, someone is listening + let sock = socket(AF_UNIX, SOCK_STREAM, 0) + guard sock >= 0 else { return false } + defer { close(sock) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutablePointer(to: &addr.sun_path.0) { ptr in + path.withCString { _ = strcpy(ptr, $0) } + } + + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(sock, $0, socklen_t(MemoryLayout.size)) + } + } + + if result == 0 { + // Connection succeeded — active listener exists + return true + } + // ECONNREFUSED = stale socket (no listener), safe to unlink + // ENOENT should not happen (we checked stat), but treat as safe + return false + } } diff --git a/Sources/CodeIsland/L10n.swift b/Sources/CodeIsland/L10n.swift index 8926ddca..d4d070a7 100644 --- a/Sources/CodeIsland/L10n.swift +++ b/Sources/CodeIsland/L10n.swift @@ -63,10 +63,6 @@ final class L10n { "smart_suppress_desc": "Don't auto-expand panel when agent's terminal tab is in foreground", "collapse_on_mouse_leave": "Auto-collapse on Mouse Leave", "collapse_on_mouse_leave_desc": "Collapse expanded panel back to notch when mouse moves away", - "sessions": "Sessions", - "tool_history_limit": "Tool History Limit", - "tool_history_limit_desc": "Max number of recent tool calls shown per session", - // Appearance "preview": "Preview", "panel": "Panel", @@ -195,10 +191,6 @@ final class L10n { "smart_suppress_desc": "Agent 所在终端标签页在前台时不自动展开面板", "collapse_on_mouse_leave": "鼠标离开时自动收起", "collapse_on_mouse_leave_desc": "鼠标移出展开的面板后自动收回到刘海状态", - "sessions": "会话", - "tool_history_limit": "工具历史上限", - "tool_history_limit_desc": "每个会话显示的最近工具调用数量上限", - // Appearance "preview": "预览", "panel": "面板", diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index dd0ba325..1b565042 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -9,12 +9,11 @@ struct NotchPanelView: View { let screenWidth: CGFloat @AppStorage(SettingsKey.contentFontSize) private var contentFontSize = SettingsDefaults.contentFontSize - @AppStorage(SettingsKey.smartSuppress) private var smartSuppress = SettingsDefaults.smartSuppress @AppStorage(SettingsKey.hideWhenNoSession) private var hideWhenNoSession = SettingsDefaults.hideWhenNoSession - /// Delayed hover: prevents accidental expansion when mouse passes through @State private var hoverTask: Task? @State private var idleHovered = false + @State private var toolDecay = DecayState(minDuration: .seconds(2)) private var isActive: Bool { !appState.sessions.isEmpty } /// First launch / no-session state should still render a visible marker so the app @@ -33,6 +32,13 @@ struct NotchPanelView: View { /// Mascot size — fits within the menu bar height private var mascotSize: CGFloat { min(27, notchHeight - 6) } + /// Live tool text from active session — feeds the decay timer + private var liveToolText: String? { + guard let sid = appState.lastInputSessionId, + let session = appState.sessions[sid] else { return nil } + return session.toolDescription ?? session.currentTool + } + /// Minimum wing width needed to display compact bar content private var compactWingWidth: CGFloat { mascotSize + 14 } @@ -42,9 +48,7 @@ struct NotchPanelView: View { if showIdleIndicator { return idleHovered ? notchW + compactWingWidth * 2 + 80 : notchW + compactWingWidth * 2 } if !isActive { return hasNotch ? notchW - 20 : notchW } if shouldShowExpanded { return min(max(notchW + 200, 580), maxWidth) } - let wing = compactWingWidth - let extra: CGFloat = appState.status == .idle ? 0 : 20 - return notchW + wing * 2 + extra + return notchW + compactWingWidth * 2 } var body: some View { @@ -58,17 +62,16 @@ struct NotchPanelView: View { Spacer(minLength: 0) } else if let sid = appState.lastInputSessionId, let session = appState.sessions[sid], - let msg = session.toolDescription - ?? session.currentTool + let msg = toolDecay.displayedText ?? session.lastAssistantMessage ?? session.lastUserPrompt { Text(msg.replacingOccurrences(of: "\n", with: " ")) .font(.system(size: 12, weight: .regular, design: .monospaced)) - .foregroundStyle(.white.opacity(0.6)) + .foregroundStyle(.white.opacity(toolDecay.displayedText != nil ? 0.8 : 0.6)) .lineLimit(1) .truncationMode(.tail) .frame(maxWidth: .infinity) - .padding(.horizontal, hasNotch ? notchW / 2 : 4) + .padding(.horizontal, 4) } else { Spacer(minLength: hasNotch ? notchW : 0) } @@ -200,18 +203,15 @@ struct NotchPanelView: View { } // Respect collapseOnMouseLeave setting if !hovering && !SettingsManager.shared.collapseOnMouseLeave { return } - // Smart suppress: don't auto-expand when active session's terminal is foreground - if hovering && smartSuppress { - if let delegate = NSApp.delegate as? AppDelegate, - let pc = delegate.panelController, - pc.isActiveTerminalForeground() { - return - } - } if hovering { - // Delay expansion to avoid accidental triggers hoverTask?.cancel() + hoverTask = nil + + // Already expanded — nothing to do + guard !appState.surface.isExpanded else { return } + + // Delay expansion to avoid accidental triggers hoverTask = Task { @MainActor in try? await Task.sleep(for: .seconds(0.2)) guard !Task.isCancelled else { return } @@ -219,19 +219,26 @@ struct NotchPanelView: View { appState.surface = .sessionList appState.cancelCompletionQueue() if appState.activeSessionId == nil { - appState.activeSessionId = appState.sessions.keys.sorted().first + appState.activeSessionId = appState.sortedSessionIds.first } } + hoverTask = nil } } else { - // Collapse with brief delay to prevent flicker on accidental mouse-out hoverTask?.cancel() + hoverTask = nil + + // Not expanded — nothing to collapse + guard appState.surface.isExpanded else { return } + + // Collapse with brief delay to absorb jitter hoverTask = Task { @MainActor in try? await Task.sleep(for: .seconds(0.15)) guard !Task.isCancelled else { return } withAnimation(NotchAnimation.close) { appState.surface = .collapsed } + hoverTask = nil // Show queued completions that arrived while user was hovering appState.completionQueue.flushIfNeeded() } @@ -243,6 +250,9 @@ struct NotchPanelView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .animation(NotchAnimation.open, value: appState.surface) + .onChange(of: liveToolText) { _, newValue in + toolDecay.update(newValue) + } } } @@ -256,7 +266,7 @@ private struct CompactLeftWing: View { let mascotSize: CGFloat private var displaySession: SessionSnapshot? { - let sid = appState.rotatingSessionId ?? appState.activeSessionId ?? appState.sessions.keys.sorted().first + let sid = appState.rotatingSessionId ?? appState.activeSessionId ?? appState.sortedSessionIds.first guard let sid else { return nil } return appState.sessions[sid] } diff --git a/Sources/CodeIsland/NotchSharedHelpers.swift b/Sources/CodeIsland/NotchSharedHelpers.swift index c334c625..8027eeed 100644 --- a/Sources/CodeIsland/NotchSharedHelpers.swift +++ b/Sources/CodeIsland/NotchSharedHelpers.swift @@ -148,3 +148,37 @@ func stripDirectives(_ text: String) -> String { let cleaned = result.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) return cleaned } + +// MARK: - Decay Text + +/// Holds a text value visible for at least `minDuration` seconds after the source goes nil. +/// New non-nil values replace immediately; nil triggers a delayed fade-out. +@Observable +@MainActor +final class DecayState { + private(set) var displayedText: String? + private var decayTask: Task? + private let minDuration: Duration + + init(minDuration: Duration = .seconds(2)) { + self.minDuration = minDuration + } + + func update(_ newValue: String?) { + if let text = newValue { + decayTask?.cancel() + decayTask = nil + displayedText = text + } else if displayedText != nil && decayTask == nil { + let duration = minDuration + decayTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: duration) + guard !Task.isCancelled, let self else { return } + withAnimation(.easeOut(duration: 0.3)) { + self.displayedText = nil + } + self.decayTask = nil + } + } + } +} diff --git a/Sources/CodeIsland/PanelWindowController.swift b/Sources/CodeIsland/PanelWindowController.swift index 599a02e3..0f065b93 100644 --- a/Sources/CodeIsland/PanelWindowController.swift +++ b/Sources/CodeIsland/PanelWindowController.swift @@ -577,15 +577,6 @@ class PanelWindowController: NSObject, NSWindowDelegate { return false } - /// Fast check: is the terminal running the active session the foreground app? - /// Main-thread safe — no AppleScript or subprocess calls. - func isActiveTerminalForeground() -> Bool { - guard let sessionId = appState.activeSessionId, - let session = appState.sessions[sessionId], - session.termApp != nil else { return false } - return TerminalVisibilityDetector.isTerminalFrontmostForSession(session) - } - deinit { autoScreenPollerTask?.cancel() fullscreenPollerTask?.cancel() diff --git a/Sources/CodeIsland/ProcessMonitorService.swift b/Sources/CodeIsland/ProcessMonitorService.swift index 26a927b7..92754ccc 100644 --- a/Sources/CodeIsland/ProcessMonitorService.swift +++ b/Sources/CodeIsland/ProcessMonitorService.swift @@ -12,6 +12,8 @@ final class ProcessMonitorService { /// Parameters: sessionId, exitTime (so caller can check for fresh activity). var onSessionExpired: ((String, Date) -> Void)? + weak var appState: AppState? + func isMonitoring(_ sessionId: String) -> Bool { monitors[sessionId] != nil } func pid(for sessionId: String) -> pid_t? { monitors[sessionId]?.pid } @@ -36,34 +38,11 @@ final class ProcessMonitorService { } } - /// Start monitoring using a known PID or fall back to scanning by CWD. - /// `onPidFound` is called (on MainActor) when the async scan resolves a PID, - /// so the caller can persist it on the session record. - func tryMonitor( - sessionId: String, - cliPid: pid_t?, - cwd: String?, - onPidFound: ((String, pid_t) -> Void)? = nil - ) { + /// Start monitoring using a known PID from the bridge. + func tryMonitor(sessionId: String, cliPid: pid_t?) { guard !isMonitoring(sessionId) else { return } - - // Primary: use PID from bridge - if let pid = cliPid, pid > 0, kill(pid, 0) == 0 { - monitor(sessionId: sessionId, pid: pid) - return - } - - // Fallback: scan for Claude Code processes by CWD - guard let cwd = cwd else { return } - Task.detached { - let pid = Self.findPidForCwd(cwd) - await MainActor.run { [weak self] in - guard let self, let pid else { return } - guard !self.isMonitoring(sessionId) else { return } - onPidFound?(sessionId, pid) - self.monitor(sessionId: sessionId, pid: pid) - } - } + guard let pid = cliPid, pid > 0, kill(pid, 0) == 0 else { return } + monitor(sessionId: sessionId, pid: pid) } func stop(sessionId: String) { @@ -75,20 +54,20 @@ final class ProcessMonitorService { for key in Array(monitors.keys) { stop(sessionId: key) } } - // MARK: - Static helpers - - nonisolated static func findPidForCwd(_ cwd: String) -> pid_t? { - for pid in ProcessScanner.findClaudePids() { - if ProcessScanner.cwd(for: pid) == cwd { return pid } - } - return nil - } - // MARK: - Private private func handleProcessExit(sessionId: String, exitedPid: pid_t) { stop(sessionId: sessionId) log.debug("Process \(exitedPid) exited for session \(sessionId)") + // Check if the PID was already replaced by a new alive process + // (e.g. auto-update restarted Claude Code). If so, re-attach monitor. + if let checkPid = appState?.sessions[sessionId]?.cliPid, + checkPid > 0, checkPid != exitedPid, + kill(checkPid, 0) == 0 { + log.debug("Session \(sessionId) taken over by new PID \(checkPid), re-attaching monitor") + monitor(sessionId: sessionId, pid: checkPid) + return + } onSessionExpired?(sessionId, Date()) } } diff --git a/Sources/CodeIsland/RequestQueueService.swift b/Sources/CodeIsland/RequestQueueService.swift index e89a0326..f539ef79 100644 --- a/Sources/CodeIsland/RequestQueueService.swift +++ b/Sources/CodeIsland/RequestQueueService.swift @@ -35,23 +35,34 @@ final class RequestQueueService { } } - func approve(always: Bool) -> String? { + func approve(always: Bool, suggestionIndex: Int? = nil) -> String? { guard !permissionQueue.isEmpty else { return nil } let pending = permissionQueue.removeFirst() let responseData: Data if always { - let toolName = pending.event.toolName ?? "" + // Use permission_suggestions from Claude Code if available + let updatedPermissions: [[String: Any]] + if let suggestions = pending.event.permissionSuggestions, + let idx = suggestionIndex, idx < suggestions.count { + updatedPermissions = [suggestions[idx]] + } else if let suggestions = pending.event.permissionSuggestions, !suggestions.isEmpty { + updatedPermissions = [suggestions[0]] + } else { + // Fallback: construct addRules for this tool + let toolName = pending.event.toolName ?? "" + updatedPermissions = [[ + "type": "addRules", + "rules": [["toolName": toolName, "ruleContent": "*"]], + "behavior": "allow", + "destination": "session" + ]] + } let obj: [String: Any] = [ "hookSpecificOutput": [ "hookEventName": "PermissionRequest", "decision": [ "behavior": "allow", - "updatedPermissions": [[ - "type": "addRules", - "rules": [["toolName": toolName, "ruleContent": "*"]], - "behavior": "allow", - "destination": "session" - ]] + "updatedPermissions": updatedPermissions ] as [String: Any] ] as [String: Any] ] diff --git a/Sources/CodeIsland/SessionDiscoveryService.swift b/Sources/CodeIsland/SessionDiscoveryService.swift deleted file mode 100644 index 5fc96d1b..00000000 --- a/Sources/CodeIsland/SessionDiscoveryService.swift +++ /dev/null @@ -1,269 +0,0 @@ -import Foundation -import CoreServices -import os.log -import CodeIslandCore - -private let log = Logger(subsystem: "com.codeisland", category: "SessionDiscovery") - -struct DiscoveredSession { - let sessionId: String - let cwd: String - let tty: String? - let model: String? - let pid: pid_t? - let termBundleId: String? - let modifiedAt: Date - let recentMessages: [ChatMessage] - var source: String = "claude" -} - -@MainActor -final class SessionDiscoveryService { - private var watcherTask: Task? - private var lastFSScanTime: Date = .distantPast - - var onDiscovered: (([DiscoveredSession]) -> Void)? - - func startWatching() { - let home = FileManager.default.homeDirectoryForCurrentUser.path - let projectsPath = "\(home)/.claude/projects" - guard FileManager.default.fileExists(atPath: projectsPath) else { return } - - watcherTask = Task { [weak self] in - for await _ in Self.directoryEvents(path: projectsPath) { - guard let self else { break } - guard Date().timeIntervalSince(self.lastFSScanTime) > 3 else { continue } - self.lastFSScanTime = Date() - self.scanAndNotify() - } - } - log.info("Projects watcher started on \(projectsPath)") - } - - func stopWatching() { - watcherTask?.cancel() - watcherTask = nil - } - - private func scanAndNotify() { - Task.detached { - let sessions = Self.findActiveClaudeSessions() - await MainActor.run { [weak self] in - self?.onDiscovered?(sessions) - } - } - } - - private static func directoryEvents(path: String, latency: TimeInterval = 2.0) -> AsyncStream { - AsyncStream { continuation in - var context = FSEventStreamContext() - let ptr = UnsafeMutableRawPointer(Unmanaged.passRetained(ContinuationBox(continuation)).toOpaque()) - context.info = ptr - - let stream = FSEventStreamCreate( - nil, - { (_, info, _, _, _, _) in - guard let info else { return } - let box = Unmanaged.fromOpaque(info).takeUnretainedValue() - box.continuation.yield() - }, - &context, - [path] as CFArray, - FSEventStreamEventId(kFSEventStreamEventIdSinceNow), - latency, - FSEventStreamCreateFlags(kFSEventStreamCreateFlagUseCFTypes) - ) - - guard let stream else { - continuation.finish() - return - } - - FSEventStreamSetDispatchQueue(stream, .main) - FSEventStreamStart(stream) - - let cleanup = StreamCleanup(stream: stream, ptr: ptr) - continuation.onTermination = { _ in - cleanup.teardown() - } - } - } - - private final class StreamCleanup: @unchecked Sendable { - private let stream: FSEventStreamRef - private let ptr: UnsafeMutableRawPointer - init(stream: FSEventStreamRef, ptr: UnsafeMutableRawPointer) { - self.stream = stream - self.ptr = ptr - } - func teardown() { - FSEventStreamStop(stream) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - let _ = Unmanaged.fromOpaque(ptr).takeRetainedValue() - } - } - - private final class ContinuationBox: @unchecked Sendable { - let continuation: AsyncStream.Continuation - init(_ continuation: AsyncStream.Continuation) { self.continuation = continuation } - } - - // MARK: - Static discovery utilities - - nonisolated static func findActiveClaudeSessions() -> [DiscoveredSession] { - let claudePids = ProcessScanner.findClaudePids() - guard !claudePids.isEmpty else { return [] } - - let home = FileManager.default.homeDirectoryForCurrentUser.path - let fm = FileManager.default - var results: [DiscoveredSession] = [] - var seenSessionIds: Set = [] - - for pid in claudePids { - guard let cwd = ProcessScanner.cwd(for: pid), !cwd.isEmpty else { continue } - - // Skip subagent worktrees — they are child tasks, not independent sessions - if cwd.contains("/.claude/worktrees/agent-") || cwd.contains("/.git/worktrees/agent-") { - continue - } - - let processStart = ProcessScanner.startTime(for: pid) - - let projectDir = cwd.claudeProjectDirEncoded() - let projectPath = "\(home)/.claude/projects/\(projectDir)" - guard let files = try? fm.contentsOfDirectory(atPath: projectPath) else { continue } - - var bestFile: String? - var bestDate = Date.distantPast - for file in files where file.hasSuffix(".jsonl") { - let fullPath = "\(projectPath)/\(file)" - if let attrs = try? fm.attributesOfItem(atPath: fullPath), - let modified = attrs[.modificationDate] as? Date, - modified > bestDate { - if let start = processStart, modified < start.addingTimeInterval(-10) { - continue - } - bestDate = modified - bestFile = file - } - } - - guard let file = bestFile else { continue } - - // Skip stale transcripts: only show sessions active within last 5 minutes - if bestDate.timeIntervalSinceNow < -300 { continue } - - let sessionId = String(file.dropLast(6)) - guard !seenSessionIds.contains(sessionId) else { continue } - seenSessionIds.insert(sessionId) - - let (model, messages) = readRecentFromTranscript(path: "\(projectPath)/\(file)") - let tty = ProcessScanner.ttyPath(for: pid) - let termBundle = ProcessScanner.findTerminalBundleId(for: pid) - - results.append(DiscoveredSession( - sessionId: sessionId, - cwd: cwd, - tty: tty, - model: model, - pid: pid, - termBundleId: termBundle, - modifiedAt: bestDate, - recentMessages: messages - )) - } - return results - } - - nonisolated static func readModelFromTranscript(sessionId: String, cwd: String?) -> String? { - guard let cwd = cwd else { return nil } - let projectDir = cwd.claudeProjectDirEncoded() - let home = FileManager.default.homeDirectoryForCurrentUser.path - let path = "\(home)/.claude/projects/\(projectDir)/\(sessionId).jsonl" - guard let handle = FileHandle(forReadingAtPath: path) else { return nil } - defer { handle.closeFile() } - let chunk = handle.readData(ofLength: 32768) - guard let text = String(data: chunk, encoding: .utf8) else { return nil } - for line in text.components(separatedBy: "\n") { - guard !line.isEmpty, - let data = line.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let message = json["message"] as? [String: Any], - let model = message["model"] as? String, !model.isEmpty - else { continue } - return model - } - return nil - } - - // MARK: - Private transcript helpers - - nonisolated static func readRecentFromTranscript(path: String) -> (String?, [ChatMessage]) { - guard let handle = FileHandle(forReadingAtPath: path) else { return (nil, []) } - defer { handle.closeFile() } - - let fileSize = handle.seekToEndOfFile() - let readSize: UInt64 = min(fileSize, 65536) - handle.seek(toFileOffset: fileSize - readSize) - let data = handle.readDataToEndOfFile() - guard let text = String(data: data, encoding: .utf8) else { return (nil, []) } - - var model: String? - var userMessages: [(Int, String)] = [] - var assistantMessages: [(Int, String)] = [] - var index = 0 - - for line in text.components(separatedBy: "\n") { - guard !line.isEmpty, - let lineData = line.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any], - let message = json["message"] as? [String: Any], - let role = message["role"] as? String - else { continue } - - if model == nil, let m = message["model"] as? String, !m.isEmpty { - model = m - } - - var textContent: String? - if let content = message["content"] as? String, !content.isEmpty { - textContent = content - } else if let contentArray = message["content"] as? [[String: Any]] { - for item in contentArray { - if item["type"] as? String == "text", - let t = item["text"] as? String, !t.isEmpty { - textContent = t - break - } - } - } - - if let text = textContent { - if role == "user" { - userMessages.append((index, text)) - } else if role == "assistant" { - assistantMessages.append((index, text)) - } - } - index += 1 - } - - var combined: [(Int, ChatMessage)] = [] - for (i, text) in userMessages.suffix(3) { - if let info = TaskNotificationInfo.parse(text) { - let display = info.summary ?? "Task \(info.status)" - combined.append((i, ChatMessage(kind: .taskNotification(info), text: display))) - } else { - combined.append((i, ChatMessage(isUser: true, text: text))) - } - } - for (i, text) in assistantMessages.suffix(3) { - combined.append((i, ChatMessage(isUser: false, text: text))) - } - combined.sort { $0.0 < $1.0 } - let recent = Array(combined.suffix(3).map { $0.1 }) - - return (model, recent) - } -} diff --git a/Sources/CodeIsland/SessionListView.swift b/Sources/CodeIsland/SessionListView.swift index ebb9a5c1..6ac4a298 100644 --- a/Sources/CodeIsland/SessionListView.swift +++ b/Sources/CodeIsland/SessionListView.swift @@ -14,7 +14,7 @@ struct SessionListView: View { if let only = onlySessionId, appState.sessions[only] != nil { return [only] } - return appState.sessions.keys.sorted() + return appState.sortedSessionIds } var body: some View { @@ -266,29 +266,30 @@ private struct ProjectNameLink: View { let cardHovering: Bool var body: some View { - Text(name) - .font(.system(size: fontSize, weight: .bold, design: .monospaced)) - .foregroundStyle(color) - .lineLimit(1) - .truncationMode(.tail) - .overlay(alignment: .bottom) { - if cwd != nil { - GeometryReader { geo in - Path { path in - path.move(to: CGPoint(x: 0, y: geo.size.height)) - path.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height)) + Button { + if let cwd { NSWorkspace.shared.open(URL(fileURLWithPath: cwd)) } + } label: { + Text(name) + .font(.system(size: fontSize, weight: .bold, design: .monospaced)) + .foregroundStyle(color) + .lineLimit(1) + .truncationMode(.tail) + .overlay(alignment: .bottom) { + if cwd != nil { + GeometryReader { geo in + Path { path in + path.move(to: CGPoint(x: 0, y: geo.size.height)) + path.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height)) + } + .stroke(style: StrokeStyle(lineWidth: 1, dash: [3, 2])) + .foregroundStyle(color.opacity(cardHovering ? 0.5 : 0.2)) } - .stroke(style: StrokeStyle(lineWidth: 1, dash: [3, 2])) - .foregroundStyle(color.opacity(cardHovering ? 0.5 : 0.2)) } } - } - .onTapGesture { - if let cwd = cwd { - NSWorkspace.shared.open(URL(fileURLWithPath: cwd)) - } - } - .help(cwd != nil ? "\(L10n.shared["open_path"]) \(cwd!)" : "") + } + .buttonStyle(.plain) + .disabled(cwd == nil) + .help(cwd != nil ? "\(L10n.shared["open_path"]) \(cwd!)" : "") } } @@ -340,28 +341,9 @@ private struct SessionStatusBar: View { var body: some View { HStack(spacing: 0) { - // Model tag - if let model = session.shortModelName { - StatusChip(icon: "cpu", text: model, color: modelColor(model)) - } - - // Tool count - if !session.toolHistory.isEmpty { - StatusDot() - let successCount = session.toolHistory.filter(\.success).count - let failCount = session.toolHistory.count - successCount - HStack(spacing: 2) { - Image(systemName: "wrench") - .font(.system(size: max(7, fontSize - 3), weight: .medium)) - .foregroundStyle(dimColor) - Text("\(successCount)") - .foregroundStyle(dimColor) - if failCount > 0 { - Text("/\(failCount)") - .foregroundStyle(Color(red: 1.0, green: 0.45, blue: 0.35).opacity(0.7)) - } - } - .font(.system(size: max(8, fontSize - 1), weight: .medium, design: .monospaced)) + // Elapsed time since user sent message + if let startedAt = session.processingStartedAt, session.status != .idle { + ElapsedTimerView(startedAt: startedAt, fontSize: fontSize) } // Active subagents @@ -397,29 +379,26 @@ private struct SessionStatusBar: View { } } - private func modelColor(_ model: String) -> Color { - switch model.lowercased() { - case "opus": return Color(red: 0.85, green: 0.6, blue: 1.0) - case "sonnet": return Color(red: 0.5, green: 0.8, blue: 1.0) - case "haiku": return Color(red: 0.4, green: 0.9, blue: 0.7) - default: return Color.white.opacity(0.5) - } - } } -private struct StatusChip: View { - let icon: String - let text: String - let color: Color +private struct ElapsedTimerView: View { + let startedAt: Date + let fontSize: CGFloat var body: some View { - HStack(spacing: 2) { - Image(systemName: icon) - .font(.system(size: 7, weight: .medium)) - Text(text) - .font(.system(size: 9, weight: .medium, design: .monospaced)) + TimelineView(.periodic(from: startedAt, by: 1)) { context in + let elapsed = Int(context.date.timeIntervalSince(startedAt)) + let minutes = elapsed / 60 + let seconds = elapsed % 60 + HStack(spacing: 3) { + Image(systemName: "clock") + .font(.system(size: max(7, fontSize - 2), weight: .medium)) + .foregroundStyle(Color.white.opacity(0.4)) + Text(minutes > 0 ? "\(minutes)m\(String(format: "%02d", seconds))s" : "\(seconds)s") + .font(.system(size: max(8, fontSize - 1), weight: .medium, design: .monospaced)) + .foregroundStyle(Color.white.opacity(0.5)) + } } - .foregroundStyle(color) } } @@ -453,6 +432,9 @@ private struct SessionCard: View { } var body: some View { + Button { + TerminalActivator.activate(session: session, sessionId: sessionId) + } label: { VStack(alignment: .leading, spacing: 6) { // Header: mascot + project name + session info + status HStack(alignment: .center, spacing: 6) { @@ -537,11 +519,10 @@ private struct SessionCard: View { .fill(hovering ? Color(white: 1, opacity: 0.10) : Color(white: 1, opacity: 0.05)) ) .padding(.horizontal, 6) + } // end Button label + .buttonStyle(.plain) .contentShape(Rectangle()) .onHover { h in withAnimation(NotchAnimation.micro) { hovering = h } } - .onTapGesture { - TerminalActivator.activate(session: session, sessionId: sessionId) - } } /// Collapse consecutive blank lines and trim leading/trailing whitespace @@ -595,12 +576,19 @@ private struct WorkingIndicator: View { let session: SessionSnapshot let fontSize: CGFloat let aiLineLimit: Int? + @State private var toolDecay = DecayState(minDuration: .seconds(2)) private let toolColor = Color(red: 0.3, green: 0.85, blue: 0.4) private let agentColor = Color(red: 0.5, green: 0.8, blue: 1.0) private let dimColor = Color.white.opacity(0.5) private let prefixColor = Color(red: 0.85, green: 0.47, blue: 0.34) + /// Live tool text from session — nil when no tool running + private var liveToolText: String? { + guard let tool = session.currentTool else { return nil } + return session.toolDescription ?? tool + } + var body: some View { VStack(alignment: .leading, spacing: 2) { // Active subagents detail @@ -609,51 +597,71 @@ private struct WorkingIndicator: View { .sorted { $0.startTime < $1.startTime } if !activeSubagents.isEmpty { ForEach(activeSubagents, id: \.agentId) { sub in - HStack(spacing: 4) { - Text("⊢") - .font(.system(size: fontSize, weight: .bold, design: .monospaced)) - .foregroundStyle(agentColor.opacity(0.6)) - Text(sub.agentType) - .font(.system(size: fontSize, weight: .medium, design: .monospaced)) - .foregroundStyle(agentColor.opacity(0.8)) - if let tool = sub.currentTool { - Text("→") - .font(.system(size: fontSize - 1, design: .monospaced)) - .foregroundStyle(dimColor) - Text(sub.toolDescription ?? tool) - .font(.system(size: fontSize, design: .monospaced)) - .foregroundStyle(.white.opacity(0.65)) - .lineLimit(1) - .truncationMode(.tail) - } - } + SubagentRow(sub: sub, fontSize: fontSize, agentColor: agentColor, dimColor: dimColor) } } - // Main thread: current tool or last message preview - if let tool = session.currentTool { + // Main thread: current tool or decayed last tool + if let displayed = toolDecay.displayedText { HStack(spacing: 4) { Text("$") .font(.system(size: fontSize, weight: .bold, design: .monospaced)) .foregroundStyle(prefixColor) - Text(tool) + Text(displayed) .font(.system(size: fontSize, weight: .medium, design: .monospaced)) - .foregroundStyle(toolColor.opacity(0.8)) - if let desc = session.toolDescription, desc != tool { - Text(desc) - .font(.system(size: fontSize, design: .monospaced)) - .foregroundStyle(.white.opacity(0.65)) - .lineLimit(1) - .truncationMode(.tail) - } + .foregroundStyle(toolColor.opacity(session.currentTool != nil ? 0.8 : 0.5)) + .lineLimit(1) + .truncationMode(.tail) } } // No "thinking" — mascot icon already indicates active status } + .onChange(of: liveToolText) { _, newValue in + toolDecay.update(newValue) + } } } +// MARK: - Subagent Row + +private struct SubagentRow: View { + let sub: SubagentState + let fontSize: CGFloat + let agentColor: Color + let dimColor: Color + @State private var toolDecay = DecayState(minDuration: .seconds(2)) + + private var liveToolText: String? { + guard let tool = sub.currentTool else { return nil } + return sub.toolDescription ?? tool + } + + var body: some View { + HStack(spacing: 4) { + Text("├") + .font(.system(size: fontSize, weight: .medium, design: .monospaced)) + .foregroundStyle(agentColor.opacity(0.4)) + Text(sub.agentType) + .font(.system(size: fontSize, weight: .medium, design: .monospaced)) + .foregroundStyle(agentColor.opacity(0.8)) + if let displayed = toolDecay.displayedText { + Text("→") + .font(.system(size: fontSize - 1, design: .monospaced)) + .foregroundStyle(dimColor) + Text(displayed) + .font(.system(size: fontSize, design: .monospaced)) + .foregroundStyle(.white.opacity(sub.currentTool != nil ? 0.65 : 0.4)) + .lineLimit(1) + .truncationMode(.tail) + } + } + .onChange(of: liveToolText) { _, newValue in + toolDecay.update(newValue) + } + } +} + // MARK: - Task Notification Row private struct TaskNotificationRow: View { diff --git a/Sources/CodeIsland/SessionPersistence.swift b/Sources/CodeIsland/SessionPersistence.swift deleted file mode 100644 index 0238e79a..00000000 --- a/Sources/CodeIsland/SessionPersistence.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation -import CodeIslandCore - -private struct PersistedEntry: Codable { - let sessionId: String - let snapshot: SessionSnapshot -} - -enum SessionPersistence { - private static let dirPath = FileManager.default.homeDirectoryForCurrentUser.path + "/.codeisland" - private static let filePath = dirPath + "/sessions.json" - - static func save(_ sessions: [String: SessionSnapshot]) { - let entries = sessions.map { PersistedEntry(sessionId: $0.key, snapshot: $0.value) } - do { - try FileManager.default.createDirectory(atPath: dirPath, withIntermediateDirectories: true) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - let data = try encoder.encode(entries) - try data.write(to: URL(fileURLWithPath: filePath), options: .atomic) - } catch {} - } - - static func load() -> [String: SessionSnapshot] { - guard let data = try? Data(contentsOf: URL(fileURLWithPath: filePath)) else { return [:] } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - guard let entries = try? decoder.decode([PersistedEntry].self, from: data) else { return [:] } - return Dictionary(entries.map { ($0.sessionId, $0.snapshot) }, uniquingKeysWith: { _, b in b }) - } - - static func clear() { - try? FileManager.default.removeItem(atPath: filePath) - } -} diff --git a/Sources/CodeIsland/Settings.swift b/Sources/CodeIsland/Settings.swift index 26c37228..1a8ad48a 100644 --- a/Sources/CodeIsland/Settings.swift +++ b/Sources/CodeIsland/Settings.swift @@ -31,9 +31,6 @@ enum SettingsKey { static let soundPromptSubmit = "soundPromptSubmit" static let soundBoot = "soundBoot" - // Advanced - static let maxToolHistory = "maxToolHistory" - // Mascot static let mascotSpeed = "mascotSpeed" @@ -60,8 +57,6 @@ struct SettingsDefaults { static let soundPromptSubmit = false static let soundBoot = true - static let maxToolHistory = 20 - static let mascotSpeed = 100 // percentage: 0–300, 0 = silent } @@ -92,7 +87,6 @@ class SettingsManager { SettingsKey.soundApprovalNeeded: SettingsDefaults.soundApprovalNeeded, SettingsKey.soundPromptSubmit: SettingsDefaults.soundPromptSubmit, SettingsKey.soundBoot: SettingsDefaults.soundBoot, - SettingsKey.maxToolHistory: SettingsDefaults.maxToolHistory, SettingsKey.mascotSpeed: SettingsDefaults.mascotSpeed, ]) } @@ -146,9 +140,4 @@ class SettingsManager { } - var maxToolHistory: Int { - get { defaults.integer(forKey: SettingsKey.maxToolHistory) } - set { defaults.set(newValue, forKey: SettingsKey.maxToolHistory) } - } - } diff --git a/Sources/CodeIsland/SettingsBehaviorPage.swift b/Sources/CodeIsland/SettingsBehaviorPage.swift index 028db976..9c96562f 100644 --- a/Sources/CodeIsland/SettingsBehaviorPage.swift +++ b/Sources/CodeIsland/SettingsBehaviorPage.swift @@ -8,7 +8,6 @@ struct BehaviorPage: View { @AppStorage(SettingsKey.hideWhenNoSession) private var hideWhenNoSession = SettingsDefaults.hideWhenNoSession @AppStorage(SettingsKey.smartSuppress) private var smartSuppress = SettingsDefaults.smartSuppress @AppStorage(SettingsKey.collapseOnMouseLeave) private var collapseOnMouseLeave = SettingsDefaults.collapseOnMouseLeave - @AppStorage(SettingsKey.maxToolHistory) private var maxToolHistory = SettingsDefaults.maxToolHistory var body: some View { Form { @@ -39,17 +38,6 @@ struct BehaviorPage: View { ) } - Section(l10n["sessions"]) { - Picker(selection: $maxToolHistory) { - Text("10").tag(10) - Text("20").tag(20) - Text("50").tag(50) - Text("100").tag(100) - } label: { - Text(l10n["tool_history_limit"]) - Text(l10n["tool_history_limit_desc"]) - } - } } .formStyle(.grouped) } diff --git a/Sources/CodeIsland/SoundManager.swift b/Sources/CodeIsland/SoundManager.swift index 08a535ca..64ff13fb 100644 --- a/Sources/CodeIsland/SoundManager.swift +++ b/Sources/CodeIsland/SoundManager.swift @@ -18,6 +18,12 @@ class SoundManager { private var soundCache: [String: NSSound] = [:] + /// Per-session+event cooldown to prevent rapid-fire notifications. + /// Keyed by "sessionId:eventName" so distinct event types don't suppress each other + /// (e.g. a PreToolUse sound won't suppress a subsequent Stop completion chime). + private static let cooldown: TimeInterval = 2.0 + private var lastSoundTimes: [String: Date] = [:] + private init() { // Pre-load all sounds into cache for entry in Self.eventSounds { @@ -27,14 +33,32 @@ class SoundManager { } } - /// Called from AppState.handleEvent() to trigger appropriate sounds - func handleEvent(_ eventName: String) { + /// Called from AppState.executeEffect() to trigger appropriate sounds. + /// `sessionId` enables per-session cooldown; `interactive` suppresses non-interactive sessions. + func handleEvent(_ eventName: String, sessionId: String? = nil, interactive: Bool = true) { + guard interactive else { return } guard defaults.bool(forKey: SettingsKey.soundEnabled) else { return } guard let entry = Self.eventSounds.first(where: { $0.event == eventName }) else { return } guard defaults.bool(forKey: entry.key) else { return } + // Per-session+event cooldown: collapse rapid-fire same-type events + if let sid = sessionId { + let cooldownKey = "\(sid):\(eventName)" + let now = Date() + if let lastPlayed = lastSoundTimes[cooldownKey], + now.timeIntervalSince(lastPlayed) < Self.cooldown { + return + } + lastSoundTimes[cooldownKey] = now + } play(entry.sound) } + /// Clear all cooldowns for a removed session + func clearCooldown(for sessionId: String) { + let prefix = "\(sessionId):" + lastSoundTimes = lastSoundTimes.filter { !$0.key.hasPrefix(prefix) } + } + /// Play boot sound on app launch func playBoot() { guard defaults.bool(forKey: SettingsKey.soundEnabled) else { return } diff --git a/Sources/CodeIsland/TerminalActivator.swift b/Sources/CodeIsland/TerminalActivator.swift index e114ecad..e14264ef 100644 --- a/Sources/CodeIsland/TerminalActivator.swift +++ b/Sources/CodeIsland/TerminalActivator.swift @@ -5,19 +5,11 @@ import os.log private let log = Logger(subsystem: "com.codeisland", category: "TerminalActivator") /// Activates the terminal window/tab running a specific Claude Code session. -/// Supports tab-level switching for: Ghostty, iTerm2, Terminal.app, WezTerm, kitty. -/// Falls back to app-level activation for: Alacritty, Warp, Hyper, Tabby, Rio. +/// Supports Ghostty only. Falls back to app-level activation for anything else. struct TerminalActivator { private static let knownTerminals: [(name: String, bundleId: String)] = [ - ("cmux", "com.cmuxterm.app"), ("Ghostty", "com.mitchellh.ghostty"), - ("iTerm2", "com.googlecode.iterm2"), - ("WezTerm", "com.github.wez.wezterm"), - ("kitty", "net.kovidgoyal.kitty"), - ("Alacritty", "org.alacritty"), - ("Warp", "dev.warp.Warp-Stable"), - ("Terminal", "com.apple.Terminal"), ] /// Fallback: source-based app jump for CLIs with NO terminal mode. @@ -83,54 +75,18 @@ struct TerminalActivator { termApp = raw } } - let lower = termApp.lowercased() - // --- tmux: switch pane first, then fall through to terminal-specific activation --- + // --- tmux: switch pane first, then fall through to terminal activation --- if let pane = session.tmuxPane, !pane.isEmpty { activateTmux(pane: pane) } - // In tmux, use the client TTY (outer terminal) for tab matching, - // since ttyPath is the inner tmux pty which won't match the terminal's tab. - let inTmux = session.tmuxPane != nil && !(session.tmuxPane ?? "").isEmpty - let effectiveTty = inTmux - ? (session.tmuxClientTty ?? session.ttyPath) - : session.ttyPath - - // --- Tab-level switching (5 terminals) --- - - if lower.contains("iterm") { - if let itermId = session.itermSessionId, !itermId.isEmpty { - activateITerm(sessionId: itermId) - } else { - bringToFront("iTerm2") - } - return - } - - if lower == "ghostty" { + if termApp.lowercased() == "ghostty" { let displayId = session.displaySessionId(sessionId: sessionId ?? "") activateGhostty(cwd: session.cwd, sessionId: displayId, source: session.source) - return - } - - if lower.contains("terminal") || lower.contains("apple_terminal") { - activateTerminalApp(ttyPath: effectiveTty) - return - } - - if lower.contains("wezterm") || lower.contains("wez") { - activateWezTerm(ttyPath: effectiveTty, cwd: session.cwd) - return - } - - if lower.contains("kitty") { - activateKitty(windowId: session.kittyWindowId, cwd: session.cwd, source: session.source) - return + } else { + bringToFront(termApp) } - - // --- App-level only (Alacritty, Warp, Hyper, Tabby, Rio, etc.) --- - bringToFront(termApp) } // MARK: - Ghostty (AppleScript: match by CWD + session ID in title) @@ -185,108 +141,6 @@ struct TerminalActivator { runAppleScript(script) } - // MARK: - iTerm2 (AppleScript: match by session ID) - - private static func activateITerm(sessionId: String) { - if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == "com.googlecode.iterm2" }) { - if app.isHidden { app.unhide() } - app.activate() - } - let script = """ - try - tell application "iTerm2" - repeat with aWindow in windows - if miniaturized of aWindow then set miniaturized of aWindow to false - repeat with aTab in tabs of aWindow - repeat with aSession in sessions of aTab - if unique ID of aSession is "\(escapeAppleScript(sessionId))" then - set miniaturized of aWindow to false - select aTab - select aSession - return - end if - end repeat - end repeat - end repeat - end tell - end try - """ - runAppleScript(script) - } - - // MARK: - Terminal.app (AppleScript: match by TTY) - - private static func activateTerminalApp(ttyPath: String?) { - guard let tty = ttyPath, !tty.isEmpty else { bringToFront("Terminal"); return } - let escaped = escapeAppleScript(tty) - let script = """ - tell application "Terminal" - repeat with w in windows - repeat with t in tabs of w - if tty of t is "\(escaped)" then - if miniaturized of w then set miniaturized of w to false - set selected tab of w to t - set index of w to 1 - end if - end repeat - end repeat - activate - end tell - """ - runAppleScript(script) - } - - // MARK: - WezTerm (CLI: wezterm cli list + activate-tab) - - private static func activateWezTerm(ttyPath: String?, cwd: String?) { - bringToFront("WezTerm") - guard let bin = findBinary("wezterm") else { return } - Task.detached(priority: .userInitiated) { - guard let json = runProcess(bin, args: ["cli", "list", "--format", "json"]), - let panes = try? JSONSerialization.jsonObject(with: json) as? [[String: Any]] else { return } - - // Find tab: prefer TTY match, fallback to CWD - var tabId: Int? - if let tty = ttyPath { - tabId = panes.first(where: { ($0["tty_name"] as? String) == tty })?["tab_id"] as? Int - } - if tabId == nil, let cwd = cwd { - let cwdUrl = "file://" + cwd - tabId = panes.first(where: { - guard let paneCwd = $0["cwd"] as? String else { return false } - return paneCwd == cwdUrl || paneCwd == cwd - })?["tab_id"] as? Int - } - - if let id = tabId { - _ = runProcess(bin, args: ["cli", "activate-tab", "--tab-id", "\(id)"]) - } - } - } - - // MARK: - kitty (CLI: kitten @ focus-window/focus-tab) - - private static func activateKitty(windowId: String?, cwd: String?, source: String = "claude") { - bringToFront("kitty") - guard let bin = findBinary("kitten") else { return } - - // Prefer window ID for precise switching - if let windowId = windowId, !windowId.isEmpty { - Task.detached(priority: .userInitiated) { - _ = runProcess(bin, args: ["@", "focus-window", "--match", "id:\(windowId)"]) - } - return - } - - // Fallback to CWD matching, then title with source keyword - guard let cwd = cwd, !cwd.isEmpty else { return } - Task.detached(priority: .userInitiated) { - if runProcess(bin, args: ["@", "focus-tab", "--match", "cwd:\(cwd)"]) == nil { - _ = runProcess(bin, args: ["@", "focus-tab", "--match", "title:\(source)"]) - } - } - } - // MARK: - tmux (CLI: tmux select-window/select-pane) private static func activateTmux(pane: String) { @@ -302,18 +156,7 @@ struct TerminalActivator { private static func bringToFront(_ termApp: String) { let name: String - let lower = termApp.lowercased() - if lower.contains("cmux") { name = "cmux" } - else if lower == "ghostty" { name = "Ghostty" } - else if lower.contains("iterm") { name = "iTerm2" } - else if lower.contains("terminal") || lower.contains("apple_terminal") { name = "Terminal" } - else if lower.contains("wezterm") || lower.contains("wez") { name = "WezTerm" } - else if lower.contains("alacritty") || lower.contains("lacritty") { name = "Alacritty" } - else if lower.contains("kitty") { name = "kitty" } - else if lower.contains("warp") { name = "Warp" } - else if lower.contains("hyper") { name = "Hyper" } - else if lower.contains("tabby") { name = "Tabby" } - else if lower.contains("rio") { name = "Rio" } + if termApp.lowercased() == "ghostty" { name = "Ghostty" } else { name = termApp } // Try NSRunningApplication first — handles Space switching and unhide @@ -342,44 +185,15 @@ struct TerminalActivator { return name } } - return "Terminal" + return "Ghostty" } - /// Fork a session: open a new tab in the session's terminal and run `claude --resume --fork-session` + /// Fork a session: open a new tab in Ghostty and run `claude --resume --fork-session` static func forkSession(session: SessionSnapshot, sessionId: String) { let dir = session.cwd ?? FileManager.default.homeDirectoryForCurrentUser.path let command = "claude --resume \(sessionId) --fork-session" log.info("forkSession: sessionId=\(sessionId) cwd=\(dir) termApp=\(session.termApp ?? "nil") termBundle=\(session.termBundleId ?? "nil")") - - // Resolve terminal (same logic as activate) - let termApp: String - if let bundleId = session.termBundleId, - let resolved = knownTerminals.first(where: { $0.bundleId == bundleId })?.name { - termApp = resolved - } else { - let raw = session.termApp ?? "" - if raw.isEmpty || raw.lowercased() == "tmux" || raw.lowercased() == "screen" { - termApp = detectRunningTerminal() - } else { - termApp = raw - } - } - let lower = termApp.lowercased() - - if lower.contains("iterm") { - forkInITerm(cwd: dir, command: command) - } else if lower == "ghostty" { - forkInGhostty(cwd: dir, command: command) - } else if lower.contains("terminal") || lower.contains("apple_terminal") { - forkInTerminalApp(cwd: dir, command: command) - } else if lower.contains("wezterm") || lower.contains("wez") { - forkInWezTerm(cwd: dir, command: command) - } else if lower.contains("kitty") { - forkInKitty(cwd: dir, command: command) - } else { - // Fallback: Ghostty (our primary target) - forkInGhostty(cwd: dir, command: command) - } + forkInGhostty(cwd: dir, command: command) } private static func forkInGhostty(cwd: String, command: String) { @@ -398,53 +212,6 @@ struct TerminalActivator { runAppleScript(script) } - private static func forkInITerm(cwd: String, command: String) { - let escapedCwd = escapeAppleScript(cwd) - let escapedCmd = escapeAppleScript(command) - let script = """ - tell application "iTerm2" - activate - tell current window - create tab with default profile - tell current session - write text "cd \\"\(escapedCwd)\\" && \(escapedCmd)" - end tell - end tell - end tell - """ - runAppleScript(script) - } - - private static func forkInTerminalApp(cwd: String, command: String) { - let escapedCwd = escapeAppleScript(cwd) - let escapedCmd = escapeAppleScript(command) - let script = """ - tell application "Terminal" - activate - do script "cd \\"\(escapedCwd)\\" && \(escapedCmd)" - end tell - """ - runAppleScript(script) - } - - private static func forkInWezTerm(cwd: String, command: String) { - guard let bin = findBinary("wezterm") else { return } - bringToFront("WezTerm") - let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" - Task.detached(priority: .userInitiated) { - _ = runProcess(bin, args: ["cli", "spawn", "--cwd", cwd, "--", shell, "-ic", command]) - } - } - - private static func forkInKitty(cwd: String, command: String) { - guard let bin = findBinary("kitten") else { return } - bringToFront("kitty") - let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" - Task.detached(priority: .userInitiated) { - _ = runProcess(bin, args: ["@", "launch", "--type=tab", "--cwd", cwd, shell, "-ic", command]) - } - } - /// Check (and prompt) for Accessibility permission. Returns true if granted. @discardableResult static func ensureAccessibility() -> Bool { diff --git a/Sources/CodeIsland/TerminalVisibilityDetector.swift b/Sources/CodeIsland/TerminalVisibilityDetector.swift index 1454ed73..e405436d 100644 --- a/Sources/CodeIsland/TerminalVisibilityDetector.swift +++ b/Sources/CodeIsland/TerminalVisibilityDetector.swift @@ -11,12 +11,8 @@ import CodeIslandCore /// Uses AppleScript or CLI calls that may block 50-200ms. Call from background thread only. /// /// Supported tab-level detection: -/// - iTerm2: session ID match /// - Ghostty: CWD match via System Events window title -/// - Terminal.app: TTY match on selected tab -/// - WezTerm: CLI pane query by TTY/CWD -/// - kitty: CLI window query by ID/CWD -/// - tmux: active pane match +/// - tmux: active pane match (works inside any terminal, including Ghostty) /// - Others: falls back to app-level only struct TerminalVisibilityDetector { @@ -51,6 +47,9 @@ struct TerminalVisibilityDetector { /// Full check: is the session's specific tab/pane currently visible? /// **Call from a background thread only** — AppleScript/CLI calls may block 50-200ms. + /// + /// Supports Ghostty (CWD match) and tmux (active pane match). + /// Unknown terminals return `true` — app-level is the best we can do. static func isSessionTabVisible(_ session: SessionSnapshot) -> Bool { // Fast path: terminal not even frontmost guard isTerminalFrontmostForSession(session) else { return false } @@ -61,74 +60,25 @@ struct TerminalVisibilityDetector { return false } - guard let termApp = session.termApp else { return false } - let term = termApp.lowercased() - .replacingOccurrences(of: ".app", with: "") - .replacingOccurrences(of: "apple_", with: "") - // tmux takes priority: if session runs in a tmux pane, check that pane - // regardless of which terminal app wraps tmux (iTerm2, Ghostty, etc.) + // regardless of which terminal app wraps tmux if let pane = session.tmuxPane, !pane.isEmpty { return isTmuxPaneActive(pane) } - let lower = term + guard let termApp = session.termApp else { return true } + let term = termApp.lowercased() + .replacingOccurrences(of: ".app", with: "") + .replacingOccurrences(of: "apple_", with: "") - if lower.contains("iterm") { - return isITermSessionActive(session) - } - if lower == "ghostty" { + if term == "ghostty" { return isGhosttyTabActive(session) } - if lower.contains("terminal") { - return isTerminalAppTabActive(session) - } - if lower.contains("wezterm") || lower.contains("wez") { - return isWezTermTabActive(session) - } - if lower.contains("kitty") { - return isKittyWindowActive(session) - } // Unknown terminal — app-level is the best we can do return true } - // MARK: - iTerm2 - - /// Check if the session's iTerm2 session ID matches the currently selected session. - private static func isITermSessionActive(_ session: SessionSnapshot) -> Bool { - // If we have a session ID, check precisely - if let sessionId = session.itermSessionId, !sessionId.isEmpty { - let escaped = escapeAppleScript(sessionId) - let script = """ - tell application "iTerm2" - try - set s to current session of current tab of current window - if unique ID of s is "\(escaped)" then return "true" - end try - return "false" - end tell - """ - return runAppleScriptSync(script)?.trimmingCharacters(in: .whitespacesAndNewlines) == "true" - } - // Fallback: match by CWD in the current session name/title - if let cwd = session.cwd, !cwd.isEmpty { - let dirName = escapeAppleScript((cwd as NSString).lastPathComponent) - let script = """ - tell application "iTerm2" - try - set s to current session of current tab of current window - if name of s contains "\(dirName)" then return "true" - end try - return "false" - end tell - """ - return runAppleScriptSync(script)?.trimmingCharacters(in: .whitespacesAndNewlines) == "true" - } - return true // no data to check — assume visible - } - // MARK: - Ghostty /// Check if Ghostty's front window matches this session's CWD. @@ -151,103 +101,6 @@ struct TerminalVisibilityDetector { return runAppleScriptSync(script)?.trimmingCharacters(in: .whitespacesAndNewlines) == "true" } - // MARK: - Terminal.app - - /// Check if Terminal.app's selected tab has the matching TTY. - private static func isTerminalAppTabActive(_ session: SessionSnapshot) -> Bool { - if let tty = session.ttyPath, !tty.isEmpty { - let escaped = escapeAppleScript(tty) - let script = """ - tell application "Terminal" - try - if tty of selected tab of front window is "\(escaped)" then return "true" - end try - return "false" - end tell - """ - return runAppleScriptSync(script)?.trimmingCharacters(in: .whitespacesAndNewlines) == "true" - } - // Fallback: match by CWD in title/history - if let cwd = session.cwd, !cwd.isEmpty { - let dirName = escapeAppleScript((cwd as NSString).lastPathComponent) - let script = """ - tell application "Terminal" - try - set t to selected tab of front window - set tabTitle to custom title of t - if tabTitle contains "\(dirName)" then return "true" - set tabHistory to history of t - if tabHistory contains "\(dirName)" then return "true" - end try - return "false" - end tell - """ - return runAppleScriptSync(script)?.trimmingCharacters(in: .whitespacesAndNewlines) == "true" - } - return true - } - - // MARK: - WezTerm - - /// Check if WezTerm's active pane matches by TTY or CWD. - private static func isWezTermTabActive(_ session: SessionSnapshot) -> Bool { - guard let bin = findBinary("wezterm") else { return true } - guard let json = runProcess(bin, args: ["cli", "list", "--format", "json"]), - let panes = try? JSONSerialization.jsonObject(with: json) as? [[String: Any]] else { return true } - - // Find which pane is active - guard let activePane = panes.first(where: { ($0["is_active"] as? Bool) == true }) else { return true } - - // Match by TTY - if let tty = session.ttyPath, - let paneTty = activePane["tty_name"] as? String, - paneTty == tty { return true } - - // Match by CWD - if let cwd = session.cwd, - let paneCwd = activePane["cwd"] as? String { - if paneCwd == cwd || paneCwd == "file://" + cwd { return true } - } - - return false - } - - // MARK: - kitty - - /// Check if kitty's focused window matches by window ID or CWD. - private static func isKittyWindowActive(_ session: SessionSnapshot) -> Bool { - guard let bin = findBinary("kitten") else { return true } - guard let json = runProcess(bin, args: ["@", "ls"]), - let osTabs = try? JSONSerialization.jsonObject(with: json) as? [[String: Any]] else { return true } - - // Find the focused window across all OS windows - for osWindow in osTabs { - let isFocused = (osWindow["is_focused"] as? Bool) == true - guard isFocused, let tabs = osWindow["tabs"] as? [[String: Any]] else { continue } - for tab in tabs { - let isActive = (tab["is_focused"] as? Bool) == true - guard isActive, let windows = tab["windows"] as? [[String: Any]] else { continue } - for window in windows { - let winFocused = (window["is_focused"] as? Bool) == true - guard winFocused else { continue } - - // Match by window ID - if let wid = session.kittyWindowId, - let winId = window["id"] as? Int, - "\(winId)" == wid { return true } - - // Match by CWD - if let cwd = session.cwd, - let winCwd = window["cwd"] as? String, - winCwd == cwd { return true } - - return false - } - } - } - return true - } - // MARK: - tmux /// Check if the tmux pane is the currently active one. diff --git a/Sources/CodeIslandBridge/main.swift b/Sources/CodeIslandBridge/main.swift index c16ff02c..a5c6d115 100644 --- a/Sources/CodeIslandBridge/main.swift +++ b/Sources/CodeIslandBridge/main.swift @@ -269,6 +269,17 @@ if let source = sourceTag { // not the ephemeral bash hook script that spawned us. json["_ppid"] = findCLIAncestorPid() +// Detect non-interactive mode (claude -p / claude --print) +// These sessions should not trigger sounds or UI notifications +if let cliPid = json["_ppid"] as? pid_t, cliPid > 0 { + if let args = ProcessScanner.processArgs(for: cliPid) { + let hasNonInteractiveFlag = args.contains("-p") || args.contains("--print") + if hasNonInteractiveFlag { + json["_interactive"] = false + } + } +} + // --- Serialize enriched JSON --- guard let enriched = try? JSONSerialization.data(withJSONObject: json) else { exit(1) } diff --git a/Sources/CodeIslandCore/Models.swift b/Sources/CodeIslandCore/Models.swift index 42c83b31..1f9e1179 100644 --- a/Sources/CodeIslandCore/Models.swift +++ b/Sources/CodeIslandCore/Models.swift @@ -20,15 +20,14 @@ public struct EventMetadata { public let model: String? public let permissionMode: String? public let termApp: String? - public let itermSession: String? public let tty: String? - public let kittyWindow: String? public let tmuxPane: String? public let tmuxClientTty: String? public let termBundle: String? public let ppid: Int? public let source: String? public let transcriptPath: String? + public let interactive: Bool? init(from json: [String: Any]) { cwd = json["cwd"] as? String @@ -36,15 +35,14 @@ public struct EventMetadata { model = json["model"] as? String permissionMode = json["permission_mode"] as? String termApp = json["_term_app"] as? String - itermSession = json["_iterm_session"] as? String tty = json["_tty"] as? String - kittyWindow = json["_kitty_window"] as? String tmuxPane = json["_tmux_pane"] as? String tmuxClientTty = json["_tmux_client_tty"] as? String termBundle = json["_term_bundle"] as? String ppid = json["_ppid"] as? Int source = json["_source"] as? String transcriptPath = json["transcript_path"] as? String + interactive = json["_interactive"] as? Bool } } @@ -67,6 +65,7 @@ public struct HookEvent { public let notificationOptions: [String]? public let askUserPayload: QuestionPayload? public let toolDescription: String? + public let permissionSuggestions: [[String: Any]]? public init?(from data: Data) { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { @@ -93,22 +92,13 @@ public struct HookEvent { let toolInput: [String: Any]? = jsonField(json, "tool_input", "toolInput") self.toolInput = toolInput - // Derive toolDescription - if let input = toolInput { - if let command = input["command"] as? String { self.toolDescription = command } - else if let filePath = input["file_path"] as? String { self.toolDescription = (filePath as NSString).lastPathComponent } - else if let pattern = input["pattern"] as? String { self.toolDescription = pattern } - else if let p = input["prompt"] as? String { self.toolDescription = String(p.prefix(40)) } - else if let msg = json["message"] as? String { self.toolDescription = msg } - else if let at = json["agent_type"] as? String { self.toolDescription = at } - else if let p = json["prompt"] as? String { self.toolDescription = String(p.prefix(40)) } - else { self.toolDescription = nil } - } else { - if let msg = json["message"] as? String { self.toolDescription = msg } - else if let at = json["agent_type"] as? String { self.toolDescription = at } - else if let p = json["prompt"] as? String { self.toolDescription = String(p.prefix(40)) } - else { self.toolDescription = nil } - } + // Parse permission suggestions (PermissionRequest events) + self.permissionSuggestions = jsonField(json, "permission_suggestions", "permissionSuggestions") + + // Derive toolDescription — tool-specific extraction for useful context + self.toolDescription = Self.deriveToolDescription( + toolName: self.toolName, toolInput: toolInput, json: json + ) // Parse AskUserQuestion payload from tool_input if let input = toolInput { @@ -135,6 +125,62 @@ public struct HookEvent { } } + /// Derive a human-readable tool description from tool input, tailored per tool type. + private static func deriveToolDescription( + toolName: String?, toolInput: [String: Any]?, json: [String: Any] + ) -> String? { + if let input = toolInput, let tool = toolName { + switch tool { + case "Bash": + if let desc = input["description"] as? String, !desc.isEmpty { return desc } + if let cmd = input["command"] as? String { + let line = cmd.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? cmd + return String(line.prefix(60)) + } + case "Read": + if let fp = input["file_path"] as? String { + let name = (fp as NSString).lastPathComponent + if let offset = input["offset"] as? Int { return "\(name):\(offset)" } + return name + } + case "Edit", "Write": + if let fp = input["file_path"] as? String { + return (fp as NSString).lastPathComponent + } + case "Grep": + if let pattern = input["pattern"] as? String { + let dir = (input["path"] as? String).map { " in \(($0 as NSString).lastPathComponent)" } ?? "" + return "\(pattern)\(dir)" + } + case "Glob": + if let pattern = input["pattern"] as? String { return pattern } + case "WebSearch": + if let query = input["query"] as? String { return query } + case "WebFetch": + if let url = input["url"] as? String { + if let host = URL(string: url)?.host { return host } + return String(url.prefix(40)) + } + case "Agent", "Task": + if let desc = input["description"] as? String, !desc.isEmpty { return desc } + if let prompt = input["prompt"] as? String { return String(prompt.prefix(40)) } + case "TodoWrite": + return "Updating tasks" + default: + // Generic: try common fields + if let fp = input["file_path"] as? String { return (fp as NSString).lastPathComponent } + if let pattern = input["pattern"] as? String { return pattern } + if let cmd = input["command"] as? String { return String(cmd.prefix(60)) } + if let prompt = input["prompt"] as? String { return String(prompt.prefix(40)) } + } + } + // Fallback for events without toolInput + if let msg = json["message"] as? String { return msg } + if let at = json["agent_type"] as? String { return at } + if let p = json["prompt"] as? String { return String(p.prefix(40)) } + return nil + } + /// Validate and sanitize session ID (alphanumeric, hyphens, underscores, max 256 chars) public static func sanitizeSessionId(_ raw: String?) -> String? { guard let raw, !raw.isEmpty, raw.count <= 256 else { return nil } @@ -159,24 +205,6 @@ public struct SubagentState: Sendable, Codable { } } -public struct ToolHistoryEntry: Identifiable, Sendable, Codable { - public let id: UUID - public let tool: String - public let description: String? - public let timestamp: Date - public let success: Bool - public let agentType: String? // nil = main thread - - public init(tool: String, description: String?, timestamp: Date, success: Bool, agentType: String?) { - self.id = UUID() - self.tool = tool - self.description = description - self.timestamp = timestamp - self.success = success - self.agentType = agentType - } -} - public struct ChatMessage: Identifiable, Sendable, Codable { public enum Kind: Sendable, Codable { case user diff --git a/Sources/CodeIslandCore/SessionSnapshot.swift b/Sources/CodeIslandCore/SessionSnapshot.swift index 51b846b1..1eac0783 100644 --- a/Sources/CodeIslandCore/SessionSnapshot.swift +++ b/Sources/CodeIslandCore/SessionSnapshot.swift @@ -17,7 +17,6 @@ public struct SessionSnapshot: Sendable, Codable { public var cwd: String? public var model: String? public var permissionMode: String? - public var toolHistory: [ToolHistoryEntry] = [] public var errorStreak: Int = 0 public var transcriptSize: Int64 = 0 public var subagents: [String: SubagentState] = [:] @@ -27,14 +26,13 @@ public struct SessionSnapshot: Sendable, Codable { /// Recent chat messages (max 3) for preview public var recentMessages: [ChatMessage] = [] // Terminal info for window activation - public var termApp: String? // "iTerm.app", "Apple_Terminal", etc. - public var itermSessionId: String? // iTerm2 session ID for direct activation + public var termApp: String? // "ghostty", etc. public var ttyPath: String? // /dev/ttys00X - public var kittyWindowId: String? // Kitty window ID for precise focus public var tmuxPane: String? // tmux pane identifier (%0, %1, etc.) public var tmuxClientTty: String? // tmux client TTY for real terminal detection public var termBundleId: String? // __CFBundleIdentifier for precise terminal ID public var cliPid: pid_t? // CLI process PID (from bridge _ppid) + public var processStartTime: Date? // Process creation time for PID-reuse guard public var transcriptPath: String? // Path to the JSONL transcript file public var source: String = "claude" public var interrupted: Bool = false @@ -42,14 +40,17 @@ public struct SessionSnapshot: Sendable, Codable { public var sessionTitleSource: SessionTitleSource? public var providerSessionId: String? public var tokenUsage: TokenUsage? + public var interactive: Bool = true // false for non-interactive `claude -p` sessions + public var processingStartedAt: Date? // set on UserPromptSubmit, cleared on Stop // CodingKeys excludes transient runtime fields: toolHistory, subagents private enum CodingKeys: String, CodingKey { case status, currentTool, toolDescription, lastActivity, cwd, model, permissionMode case errorStreak, transcriptSize, startTime, lastUserPrompt, lastAssistantMessage - case recentMessages, termApp, itermSessionId, ttyPath, kittyWindowId, tmuxPane - case tmuxClientTty, termBundleId, cliPid, transcriptPath, source, interrupted - case sessionTitle, sessionTitleSource, providerSessionId, tokenUsage + case recentMessages, termApp, ttyPath, tmuxPane + case tmuxClientTty, termBundleId, cliPid, processStartTime, transcriptPath, source, interrupted + case sessionTitle, sessionTitleSource, providerSessionId, tokenUsage, interactive + case processingStartedAt } public init(startTime: Date = Date()) { @@ -74,14 +75,6 @@ public struct SessionSnapshot: Sendable, Codable { } } - public mutating func recordTool(_ tool: String, description: String?, success: Bool, agentType: String?, maxHistory: Int) { - let entry = ToolHistoryEntry(tool: tool, description: description, timestamp: Date(), success: success, agentType: agentType) - toolHistory.append(entry) - if toolHistory.count > maxHistory { - toolHistory.removeFirst() - } - } - /// Display name: project folder, or short session ID public var displayName: String { if let cwd = cwd { @@ -134,6 +127,14 @@ public struct SessionSnapshot: Sendable, Codable { public var isClaude: Bool { source == "claude" } + /// Session appears active but may be stuck (not idle, not awaiting user input) + public var isStuckCandidate: Bool { + status != .idle && status != .waitingApproval && status != .waitingQuestion + } + + /// Whether terminal info is available for window activation + public var hasTerminalInfo: Bool { termApp != nil || termBundleId != nil } + /// Always false — Claude Code is CLI-only, no native app mode. /// True when the session runs inside an IDE's integrated terminal. /// We can't query IDE tab/pane state, so notification suppression should be skipped. @@ -160,13 +161,7 @@ public struct SessionSnapshot: Sendable, Codable { // Check bundle ID for terminal identification (more reliable than TERM_PROGRAM) if let bid = termBundleId { let lower = bid.lowercased() - if lower.contains("cmux") { return "cmux" } - if lower.contains("warp") { return "Warp" } if lower == "com.mitchellh.ghostty" { return "Ghostty" } - if lower.contains("iterm2") { return "iTerm2" } - if lower.contains("kitty") { return "Kitty" } - if lower.contains("alacritty") { return "Alacritty" } - if lower.contains("wezterm") { return "WezTerm" } // IDE integrated terminals if lower.contains("vscode") || lower.contains("vscodium") { return "VS Code" } if lower == "com.trae.app" { return "Trae" } @@ -188,17 +183,12 @@ public struct SessionSnapshot: Sendable, Codable { if lower.contains("panic.nova") { return "Nova" } if lower.contains("android.studio") { return "Android Studio" } if lower.contains("antigravity") { return "Antigravity" } + return bid } // Fallback to TERM_PROGRAM guard let app = termApp else { return nil } let lower = app.lowercased() - if lower.contains("cmux") { return "cmux" } if lower == "ghostty" { return "Ghostty" } - if lower.contains("iterm") { return "iTerm2" } - if lower.contains("warp") { return "Warp" } - if lower.contains("alacritty") { return "Alacritty" } - if lower.contains("kitty") { return "Kitty" } - if lower.contains("terminal") { return "Terminal" } return app } @@ -323,8 +313,7 @@ public func resolveTrackingKey( public func reduceEvent( sessions: inout [String: SessionSnapshot], event: HookEvent, - trackingKey: String? = nil, - maxHistory: Int + trackingKey: String? = nil ) -> [SideEffect] { let sessionId = trackingKey ?? event.sessionId ?? "default" let eventName = event.eventName @@ -338,6 +327,18 @@ public func reduceEvent( // Always update metadata from every event extractMetadata(into: &sessions, sessionId: sessionId, event: event) + // Clean up stale subagents (SubagentStop may not fire reliably) + if let currentSubagents = sessions[sessionId]?.subagents, !currentSubagents.isEmpty { + let staleTimeout: TimeInterval = 3 * 60 + let now = Date() + let fresh = currentSubagents.filter { _, sub in + now.timeIntervalSince(sub.startTime) < staleTimeout + } + if fresh.count != currentSubagents.count { + sessions[sessionId]?.subagents = fresh + } + } + // Route subagent-specific events if let agentId = event.agentId { let handled = handleSubagentEvent( @@ -346,7 +347,6 @@ public func reduceEvent( agentId: agentId, eventName: eventName, event: event, - maxHistory: maxHistory, effects: &effects ) if handled { return effects } @@ -367,7 +367,9 @@ public func reduceEvent( sessions[sessionId]?.status = .processing sessions[sessionId]?.currentTool = nil sessions[sessionId]?.toolDescription = nil + sessions[sessionId]?.processingStartedAt = Date() sessions[sessionId]?.lastAssistantMessage = nil // clear so collapsed bar shows user prompt + sessions[sessionId]?.subagents.removeAll() // new turn — clear stale subagents if let prompt = event.prompt, !prompt.isEmpty { // Detect — system-injected when background agent completes if let info = TaskNotificationInfo.parse(prompt) { @@ -394,6 +396,7 @@ public func reduceEvent( sessions[sessionId]?.currentTool = nil sessions[sessionId]?.toolDescription = nil sessions[sessionId]?.subagents.removeAll() + sessions[sessionId]?.processingStartedAt = nil if let msg = event.lastAssistantMessage, !msg.isEmpty { sessions[sessionId]?.lastAssistantMessage = msg sessions[sessionId]?.addRecentMessage(ChatMessage(isUser: false, text: msg)) @@ -408,6 +411,7 @@ public func reduceEvent( sessions[sessionId]?.currentTool = nil sessions[sessionId]?.toolDescription = event.errorDetails sessions[sessionId]?.subagents.removeAll() + sessions[sessionId]?.processingStartedAt = nil if let msg = event.lastAssistantMessage, !msg.isEmpty { sessions[sessionId]?.lastAssistantMessage = msg sessions[sessionId]?.addRecentMessage(ChatMessage(isUser: false, text: msg)) @@ -425,10 +429,6 @@ public func reduceEvent( case "PostToolUse": // Schema: { tool_name: string, tool_input: any, tool_response: any, tool_use_id: string } - if let tool = sessions[sessionId]?.currentTool { - let desc = sessions[sessionId]?.toolDescription - sessions[sessionId]?.recordTool(tool, description: desc, success: true, agentType: nil, maxHistory: maxHistory) - } sessions[sessionId]?.errorStreak = 0 if !isWaiting { sessions[sessionId]?.status = .processing @@ -438,10 +438,6 @@ public func reduceEvent( case "PostToolUseFailure": // Schema: { tool_name: string, tool_input: any, error: string, is_interrupt?: bool, tool_use_id: string } - if let tool = sessions[sessionId]?.currentTool { - let desc = sessions[sessionId]?.toolDescription - sessions[sessionId]?.recordTool(tool, description: desc, success: false, agentType: nil, maxHistory: maxHistory) - } let currentStreak = sessions[sessionId]?.errorStreak ?? 0 sessions[sessionId]?.errorStreak = currentStreak + 1 // is_interrupt = user pressed Ctrl+C during tool execution @@ -573,16 +569,10 @@ public func extractMetadata(into sessions: inout [String: SessionSnapshot], sess if let app = m.termApp, !app.isEmpty, app != "unknown" { sessions[sessionId]?.termApp = app } - if let ses = m.itermSession, !ses.isEmpty { - sessions[sessionId]?.itermSessionId = ses - } if let tty = m.tty, !tty.isEmpty { sessions[sessionId]?.ttyPath = tty } // Extended terminal info (from native bridge binary) - if let kitty = m.kittyWindow, !kitty.isEmpty { - sessions[sessionId]?.kittyWindowId = kitty - } if let pane = m.tmuxPane, !pane.isEmpty { sessions[sessionId]?.tmuxPane = pane } @@ -595,6 +585,9 @@ public func extractMetadata(into sessions: inout [String: SessionSnapshot], sess if let ppid = m.ppid, ppid > 0 { sessions[sessionId]?.cliPid = pid_t(ppid) } + if let interactive = m.interactive { + sessions[sessionId]?.interactive = interactive + } if let source = SessionSnapshot.normalizedSupportedSource(m.source) { sessions[sessionId]?.source = source } @@ -610,7 +603,6 @@ private func handleSubagentEvent( agentId: String, eventName: String, event: HookEvent, - maxHistory: Int, effects: inout [SideEffect] ) -> Bool { switch eventName { @@ -637,11 +629,6 @@ private func handleSubagentEvent( return true case "PostToolUse": - if let tool = sessions[sessionId]?.subagents[agentId]?.currentTool { - let agentType = sessions[sessionId]?.subagents[agentId]?.agentType - let desc = sessions[sessionId]?.subagents[agentId]?.toolDescription - sessions[sessionId]?.recordTool(tool, description: desc, success: true, agentType: agentType, maxHistory: maxHistory) - } sessions[sessionId]?.subagents[agentId]?.status = .processing sessions[sessionId]?.subagents[agentId]?.currentTool = nil sessions[sessionId]?.subagents[agentId]?.toolDescription = nil @@ -650,11 +637,6 @@ private func handleSubagentEvent( return true case "PostToolUseFailure": - if let tool = sessions[sessionId]?.subagents[agentId]?.currentTool { - let agentType = sessions[sessionId]?.subagents[agentId]?.agentType - let desc = sessions[sessionId]?.subagents[agentId]?.toolDescription - sessions[sessionId]?.recordTool(tool, description: desc, success: false, agentType: agentType, maxHistory: maxHistory) - } sessions[sessionId]?.subagents[agentId]?.status = .processing sessions[sessionId]?.subagents[agentId]?.currentTool = nil sessions[sessionId]?.subagents[agentId]?.toolDescription = nil diff --git a/Tests/CodeIslandCoreTests/ReduceEventTests.swift b/Tests/CodeIslandCoreTests/ReduceEventTests.swift index 27d09eef..f9ec9b69 100644 --- a/Tests/CodeIslandCoreTests/ReduceEventTests.swift +++ b/Tests/CodeIslandCoreTests/ReduceEventTests.swift @@ -16,14 +16,14 @@ import Foundation @Test func sessionIsCreatedAutomaticallyForUnknownSessionId() { var sessions: [String: SessionSnapshot] = [:] let event = makeEvent(["hook_event_name": "Notification", "session_id": "new-session"]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["new-session"] != nil) } @Test func missingSessionIdUsesDefaultKey() { var sessions: [String: SessionSnapshot] = [:] let event = makeEvent(["hook_event_name": "Notification"]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["default"] != nil) } @@ -37,21 +37,19 @@ import Foundation "cwd": "/home/user/project", "model": "claude-sonnet-4-6", "permission_mode": "default", - "_term_app": "iTerm2", - "_iterm_session": "iterm-abc", + "_term_app": "ghostty", "_tty": "/dev/ttys001", - "_term_bundle": "com.googlecode.iterm2", + "_term_bundle": "com.mitchellh.ghostty", "_ppid": 12345, ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) let s = sessions["s1"]! #expect(s.cwd == "/home/user/project") #expect(s.model == "claude-sonnet-4-6") #expect(s.permissionMode == "default") - #expect(s.termApp == "iTerm2") - #expect(s.itermSessionId == "iterm-abc") + #expect(s.termApp == "ghostty") #expect(s.ttyPath == "/dev/ttys001") - #expect(s.termBundleId == "com.googlecode.iterm2") + #expect(s.termBundleId == "com.mitchellh.ghostty") #expect(s.cliPid == 12345) } @@ -62,7 +60,7 @@ import Foundation "session_id": "s1", "workspace_roots": ["/workspace/root"], ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.cwd == "/workspace/root") } @@ -75,7 +73,7 @@ import Foundation "session_id": "s1", "prompt": "hello world", ]) - let effects = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + let effects = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.status == .processing) #expect(sessions["s1"]?.lastUserPrompt == "hello world") #expect(sessions["s1"]?.currentTool == nil) @@ -91,7 +89,7 @@ import Foundation "session_id": "s1", "prompt": "what is 2+2", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) let messages = sessions["s1"]?.recentMessages ?? [] #expect(messages.count == 1) #expect(messages.last?.isUser == true) @@ -109,7 +107,7 @@ import Foundation "session_id": "s1", "prompt": "new prompt", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) let messages = sessions["s1"]?.recentMessages ?? [] // Old trailing user message should be replaced, not appended #expect(messages.count == 1) @@ -124,7 +122,7 @@ import Foundation "session_id": "s1", "prompt": xml, ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.lastUserPrompt == "Build done") let msgs = sessions["s1"]?.recentMessages ?? [] #expect(msgs.last?.isTaskNotification == true) @@ -142,7 +140,7 @@ import Foundation "hook_event_name": "Stop", "session_id": "s1", ]) - let effects = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + let effects = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.status == .idle) #expect(sessions["s1"]?.currentTool == nil) #expect(effects.contains(.enqueueCompletion(sessionId: "s1"))) @@ -155,7 +153,7 @@ import Foundation "session_id": "s1", "last_assistant_message": "Here is the result", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.lastAssistantMessage == "Here is the result") let msgs = sessions["s1"]?.recentMessages ?? [] #expect(msgs.last?.isUser == false) @@ -168,7 +166,7 @@ import Foundation sessions["s1"]?.subagents["agent-1"] = SubagentState(agentId: "agent-1", agentType: "researcher") let event = makeEvent(["hook_event_name": "Stop", "session_id": "s1"]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.subagents.isEmpty == true) } @@ -184,7 +182,7 @@ import Foundation "session_id": "s1", "error_details": "Rate limit exceeded", ]) - let effects = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + let effects = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.status == .idle) #expect(sessions["s1"]?.toolDescription == "Rate limit exceeded") #expect(effects.contains(.enqueueCompletion(sessionId: "s1"))) @@ -197,7 +195,7 @@ import Foundation "session_id": "s1", "last_assistant_message": "Partial response", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.lastAssistantMessage == "Partial response") } @@ -211,7 +209,7 @@ import Foundation "tool_name": "Bash", "tool_input": ["command": "ls -la"], ]) - let effects = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + let effects = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.status == .running) #expect(sessions["s1"]?.currentTool == "Bash") #expect(effects.contains(.playSound("PreToolUse"))) @@ -227,7 +225,7 @@ import Foundation "session_id": "s1", "tool_name": "Bash", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.status == .waitingApproval) } @@ -241,13 +239,13 @@ import Foundation "session_id": "s1", "tool_name": "Read", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.status == .waitingQuestion) } // MARK: - PostToolUse - @Test func postToolUseRecordsHistoryAndClearsTool() { + @Test func postToolUseClearsToolAndResetsErrorStreak() { var sessions: [String: SessionSnapshot] = [:] sessions["s1"] = SessionSnapshot() sessions["s1"]?.status = .running @@ -259,13 +257,10 @@ import Foundation "session_id": "s1", "tool_name": "Bash", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.status == .processing) #expect(sessions["s1"]?.currentTool == nil) #expect(sessions["s1"]?.errorStreak == 0) - #expect(sessions["s1"]?.toolHistory.count == 1) - #expect(sessions["s1"]?.toolHistory.first?.tool == "Bash") - #expect(sessions["s1"]?.toolHistory.first?.success == true) } @Test func postToolUsePreservesWaitingState() { @@ -279,7 +274,7 @@ import Foundation "session_id": "s1", "tool_name": "Write", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.status == .waitingApproval) } @@ -296,10 +291,9 @@ import Foundation "session_id": "s1", "tool_name": "Bash", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.errorStreak == 2) #expect(sessions["s1"]?.status == .processing) - #expect(sessions["s1"]?.toolHistory.first?.success == false) } @Test func postToolUseFailureWithIsInterruptSetsInterrupted() { @@ -313,7 +307,7 @@ import Foundation "tool_name": "Bash", "is_interrupt": true, ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.interrupted == true) } @@ -327,7 +321,7 @@ import Foundation "session_id": "s1", "tool_name": "Bash", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.status == .waitingApproval) } @@ -344,7 +338,7 @@ import Foundation "session_id": "s1", "tool_name": "Bash", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.status == .processing) #expect(sessions["s1"]?.currentTool == nil) } @@ -359,7 +353,7 @@ import Foundation "session_id": "s1", "tool_name": "Bash", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.status == .waitingApproval) } @@ -374,7 +368,7 @@ import Foundation "agent_id": "agent-42", "agent_type": "researcher", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) let subagent = sessions["s1"]?.subagents["agent-42"] #expect(subagent != nil) #expect(subagent?.agentType == "researcher") @@ -389,7 +383,7 @@ import Foundation "agent_type": "executor", // no agent_id — falls through to main session handling ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.status == .running) #expect(sessions["s1"]?.currentTool == "Agent") } @@ -406,7 +400,7 @@ import Foundation "session_id": "s1", "agent_id": "agent-42", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.subagents["agent-42"] == nil) } @@ -421,11 +415,49 @@ import Foundation "session_id": "s1", // no agent_id ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.status == .processing) #expect(sessions["s1"]?.currentTool == nil) } + // MARK: - Stale subagent cleanup + + @Test func staleSubagentsRemovedOnEvent() { + var sessions: [String: SessionSnapshot] = [:] + sessions["s1"] = SessionSnapshot() + // Add a subagent with a startTime 4 minutes ago (stale) + var stale = SubagentState(agentId: "old-agent", agentType: "researcher") + stale.startTime = Date().addingTimeInterval(-4 * 60) + sessions["s1"]?.subagents["old-agent"] = stale + // Add a fresh subagent + sessions["s1"]?.subagents["new-agent"] = SubagentState(agentId: "new-agent", agentType: "worker") + + // Any event should trigger cleanup + let event = makeEvent([ + "hook_event_name": "PreToolUse", + "session_id": "s1", + "tool_name": "Bash", + ]) + _ = reduceEvent(sessions: &sessions, event: event) + #expect(sessions["s1"]?.subagents["old-agent"] == nil, "Stale subagent should be removed") + #expect(sessions["s1"]?.subagents["new-agent"] != nil, "Fresh subagent should remain") + } + + @Test func userPromptSubmitClearsAllSubagents() { + var sessions: [String: SessionSnapshot] = [:] + sessions["s1"] = SessionSnapshot() + sessions["s1"]?.subagents["agent-1"] = SubagentState(agentId: "agent-1", agentType: "researcher") + sessions["s1"]?.subagents["agent-2"] = SubagentState(agentId: "agent-2", agentType: "worker") + + let event = makeEvent([ + "hook_event_name": "UserPromptSubmit", + "session_id": "s1", + "prompt": "next task", + ]) + _ = reduceEvent(sessions: &sessions, event: event) + #expect(sessions["s1"]?.subagents.isEmpty == true, "All subagents should be cleared on new prompt") + } + // MARK: - SessionStart @Test func sessionStartResetsSessionAndReturnsExpectedEffects() { @@ -440,7 +472,7 @@ import Foundation "session_id": "s1", "cwd": "/new/project", ]) - let effects = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + let effects = reduceEvent(sessions: &sessions, event: event) // Session is reset #expect(sessions["s1"]?.status == .idle) #expect(sessions["s1"]?.currentTool == nil) @@ -460,7 +492,7 @@ import Foundation "session_id": "new-session", "_ppid": 9999, ]) - let effects = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + let effects = reduceEvent(sessions: &sessions, event: event) #expect(effects.contains(.removeSession(sessionId: "old-session"))) } @@ -474,7 +506,7 @@ import Foundation "hook_event_name": "SessionEnd", "session_id": "s1", ]) - let effects = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + let effects = reduceEvent(sessions: &sessions, event: event) #expect(effects.contains(.removeSession(sessionId: "s1"))) // Should only return removeSession, nothing else #expect(effects.count == 1) @@ -488,7 +520,7 @@ import Foundation "hook_event_name": "PreCompact", "session_id": "s1", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.status == .processing) #expect(sessions["s1"]?.toolDescription == "Compacting context\u{2026}") } @@ -504,7 +536,7 @@ import Foundation "hook_event_name": "PostCompact", "session_id": "s1", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.status == .processing) #expect(sessions["s1"]?.toolDescription == nil) } @@ -518,7 +550,7 @@ import Foundation "hook_event_name": "PostCompact", "session_id": "s1", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.status == .waitingQuestion) } @@ -534,7 +566,7 @@ import Foundation "session_id": "s1", "new_cwd": "/new/path", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.cwd == "/new/path") } @@ -548,7 +580,7 @@ import Foundation "session_id": "s1", "new_cwd": "", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.cwd == "/existing/path") } @@ -564,7 +596,7 @@ import Foundation "session_id": "s1", "message": "Agent needs attention", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) #expect(sessions["s1"]?.status == .idle) } @@ -583,7 +615,7 @@ import Foundation "tool_name": "Read", "tool_input": ["file_path": "/some/file.txt"], ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event) // Main session status unchanged #expect(sessions["s1"]?.status == .processing) // Subagent updated @@ -591,7 +623,7 @@ import Foundation #expect(sessions["s1"]?.subagents["agent-1"]?.currentTool == "Read") } - @Test func postToolUseWithAgentIdRecordsToolInMainHistory() { + @Test func postToolUseWithAgentIdUpdatesSubagentStatus() { var sessions: [String: SessionSnapshot] = [:] sessions["s1"] = SessionSnapshot() sessions["s1"]?.subagents["agent-1"] = SubagentState(agentId: "agent-1", agentType: "worker") @@ -604,33 +636,11 @@ import Foundation "agent_id": "agent-1", "tool_name": "Bash", ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) - // Tool recorded in main session's tool history with agentType - #expect(sessions["s1"]?.toolHistory.count == 1) - #expect(sessions["s1"]?.toolHistory.first?.agentType == "worker") - #expect(sessions["s1"]?.toolHistory.first?.success == true) + _ = reduceEvent(sessions: &sessions, event: event) // Subagent moves to processing #expect(sessions["s1"]?.subagents["agent-1"]?.status == .processing) } - // MARK: - Tool history max limit - - @Test func toolHistoryRespectsMaxHistory() { - var sessions: [String: SessionSnapshot] = [:] - sessions["s1"] = SessionSnapshot() - - for i in 0..<5 { - sessions["s1"]?.currentTool = "Tool\(i)" - let event = makeEvent([ - "hook_event_name": "PostToolUse", - "session_id": "s1", - "tool_name": "Tool\(i)", - ]) - _ = reduceEvent(sessions: &sessions, event: event, maxHistory: 3) - } - #expect(sessions["s1"]?.toolHistory.count == 3) - } - // MARK: - Tracking key resolution (same session_id, different PIDs) @Test func resolveTrackingKeyReturnsRawSessionIdWhenNoPidConflict() { @@ -688,7 +698,7 @@ import Foundation "prompt": "from A", ]) let keyA = resolveTrackingKey(sessions: sessions, event: eventA) - _ = reduceEvent(sessions: &sessions, event: eventA, trackingKey: keyA, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: eventA, trackingKey: keyA) // Process B (PID 200) sends event with same session_id let eventB = makeEvent([ @@ -698,7 +708,7 @@ import Foundation "prompt": "from B", ]) let keyB = resolveTrackingKey(sessions: sessions, event: eventB) - _ = reduceEvent(sessions: &sessions, event: eventB, trackingKey: keyB, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: eventB, trackingKey: keyB) // Two separate entries #expect(keyA == "shared") @@ -720,7 +730,7 @@ import Foundation "prompt": "first", ]) let key1 = resolveTrackingKey(sessions: sessions, event: event1) - _ = reduceEvent(sessions: &sessions, event: event1, trackingKey: key1, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event1, trackingKey: key1) let event2 = makeEvent([ "hook_event_name": "PreToolUse", @@ -729,7 +739,7 @@ import Foundation "tool_name": "Bash", ]) let key2 = resolveTrackingKey(sessions: sessions, event: event2) - _ = reduceEvent(sessions: &sessions, event: event2, trackingKey: key2, maxHistory: 10) + _ = reduceEvent(sessions: &sessions, event: event2, trackingKey: key2) #expect(key1 == "s1") #expect(key2 == "s1") @@ -746,7 +756,7 @@ import Foundation "tool_name": "Bash", "cwd": "/my/project", ]) - let effects = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + let effects = reduceEvent(sessions: &sessions, event: event) #expect(effects.contains(.tryMonitorSession(sessionId: "s1"))) } @@ -757,7 +767,7 @@ import Foundation "hook_event_name": "Notification", "session_id": "s1", ]) - let effects = reduceEvent(sessions: &sessions, event: event, maxHistory: 10) + let effects = reduceEvent(sessions: &sessions, event: event) #expect(!effects.contains(.tryMonitorSession(sessionId: "s1"))) } } diff --git a/Tests/CodeIslandCoreTests/ToolDescriptionTests.swift b/Tests/CodeIslandCoreTests/ToolDescriptionTests.swift new file mode 100644 index 00000000..c6835b77 --- /dev/null +++ b/Tests/CodeIslandCoreTests/ToolDescriptionTests.swift @@ -0,0 +1,152 @@ +import Foundation +import Testing +@testable import CodeIslandCore + +@Suite("HookEvent.toolDescription derivation") +struct ToolDescriptionTests { + + private func makeEvent(toolName: String, toolInput: [String: Any]) -> HookEvent { + var json: [String: Any] = [ + "session_id": "test-session", + "hook_event_name": "PreToolUse", + "tool_name": toolName, + "tool_input": toolInput, + ] + let data = try! JSONSerialization.data(withJSONObject: json) + return HookEvent(from: data)! + } + + private func makeEventFromJson(_ json: [String: Any]) -> HookEvent { + let data = try! JSONSerialization.data(withJSONObject: json) + return HookEvent(from: data)! + } + + @Test func bashPrefersDescription() { + let event = makeEvent(toolName: "Bash", toolInput: [ + "command": "cd /tmp && ls -la", + "description": "List files in /tmp", + ]) + #expect(event.toolDescription == "List files in /tmp") + } + + @Test func bashFallsBackToFirstLineOfCommand() { + let event = makeEvent(toolName: "Bash", toolInput: [ + "command": "git status\ngit diff", + ]) + #expect(event.toolDescription == "git status") + } + + @Test func bashTruncatesLongCommand() { + let longCmd = String(repeating: "x", count: 100) + let event = makeEvent(toolName: "Bash", toolInput: ["command": longCmd]) + #expect(event.toolDescription?.count == 60) + } + + @Test func readShowsFilenameOnly() { + let event = makeEvent(toolName: "Read", toolInput: [ + "file_path": "/Users/dev/project/src/main.swift", + ]) + #expect(event.toolDescription == "main.swift") + } + + @Test func readShowsFilenameWithOffset() { + let event = makeEvent(toolName: "Read", toolInput: [ + "file_path": "/Users/dev/project/src/main.swift", + "offset": 42, + ]) + #expect(event.toolDescription == "main.swift:42") + } + + @Test func editShowsFilename() { + let event = makeEvent(toolName: "Edit", toolInput: [ + "file_path": "/path/to/config.json", + ]) + #expect(event.toolDescription == "config.json") + } + + @Test func writeShowsFilename() { + let event = makeEvent(toolName: "Write", toolInput: [ + "file_path": "/path/to/output.txt", + ]) + #expect(event.toolDescription == "output.txt") + } + + @Test func grepShowsPatternOnly() { + let event = makeEvent(toolName: "Grep", toolInput: [ + "pattern": "TODO|FIXME", + ]) + #expect(event.toolDescription == "TODO|FIXME") + } + + @Test func grepShowsPatternWithDir() { + let event = makeEvent(toolName: "Grep", toolInput: [ + "pattern": "import Foundation", + "path": "/Users/dev/project/Sources", + ]) + #expect(event.toolDescription == "import Foundation in Sources") + } + + @Test func globShowsPattern() { + let event = makeEvent(toolName: "Glob", toolInput: [ + "pattern": "**/*.swift", + ]) + #expect(event.toolDescription == "**/*.swift") + } + + @Test func webSearchShowsQuery() { + let event = makeEvent(toolName: "WebSearch", toolInput: [ + "query": "swift concurrency tutorial", + ]) + #expect(event.toolDescription == "swift concurrency tutorial") + } + + @Test func webFetchShowsDomain() { + let event = makeEvent(toolName: "WebFetch", toolInput: [ + "url": "https://developer.apple.com/documentation/swiftui", + ]) + #expect(event.toolDescription == "developer.apple.com") + } + + @Test func webFetchFallsBackToTruncatedUrl() { + let event = makeEvent(toolName: "WebFetch", toolInput: [ + "url": "not-a-valid-url", + ]) + #expect(event.toolDescription == "not-a-valid-url") + } + + @Test func agentShowsDescription() { + let event = makeEvent(toolName: "Agent", toolInput: [ + "description": "Research codebase", + "prompt": "Find all API endpoints in the project", + ]) + #expect(event.toolDescription == "Research codebase") + } + + @Test func agentFallsBackToPromptPrefix() { + let event = makeEvent(toolName: "Agent", toolInput: [ + "prompt": "Find all API endpoints in the project and document them thoroughly", + ]) + #expect(event.toolDescription == "Find all API endpoints in the project an") + } + + @Test func todoWriteReturnsFixed() { + let event = makeEvent(toolName: "TodoWrite", toolInput: ["todos": []]) + #expect(event.toolDescription == "Updating tasks") + } + + @Test func unknownToolTriesCommonFields() { + let event = makeEvent(toolName: "CustomTool", toolInput: [ + "file_path": "/path/to/file.rs", + ]) + #expect(event.toolDescription == "file.rs") + } + + @Test func noToolInputFallsBackToMessage() { + let event = makeEventFromJson([ + "session_id": "test", + "hook_event_name": "Notification", + "message": "Build completed", + ]) + #expect(event.toolDescription == "Build completed") + } +}