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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/Hopet/App/MenuBarItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public final class MenuBarItem {
}

@objc private func openPrefs() { router.openPreferences() }
@objc private func togglePets() { router.toggleAllPets() }
@objc private func togglePets() { router.togglePet() }
@objc private func quit() { NSApp.terminate(nil) }

private static func loadStatusBarIcon() -> NSImage? {
Expand Down
81 changes: 59 additions & 22 deletions Sources/Hopet/App/SceneRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ public final class SceneRouter {
self.thinkingTimer = ThinkingTimer(registry: registry)
self.decayTimer = CompletedDecayTimer(registry: registry)
self.permissionPrompter = prompter
self.router = EventRouter(registry: registry, permissionPrompter: prompter)
self.router = EventRouter(
registry: registry,
permissionPrompter: prompter,
isToolListening: { [weak configStore] tool in
configStore?.current.listeners[tool] ?? true
}
)

self.petWindowController = PetWindowController(
registry: registry,
Expand Down Expand Up @@ -67,19 +73,39 @@ public final class SceneRouter {
}

applyAppearance(configStore.current.appearance)
applyListeners(configStore.current.listeners)
ensureHooksInstalled()
themes.reload()

// 监听 config 变化:appearance 即时应用,listeners 在 Toggle 翻转时同步装/卸 hooks。
configStore.$current
.map(\.appearance)
.removeDuplicates()
.sink { [weak self] in self?.applyAppearance($0) }
.store(in: &configCancellables)

// @Published 在 willSet 阶段 emit——sink 拿到的 configStore.current 仍是旧值,
// 必须用闭包参数里的新 listeners,否则 toggle off 后 sweep 看到的还是 on。
configStore.$current
.map(\.listeners)
.removeDuplicates()
.sink { [weak self] in self?.applyListeners($0) }
.sink { [weak self] listeners in self?.enforceListenerMute(listeners: listeners) }
.store(in: &configCancellables)

// pending 清空 / 状态切换时再扫一遍,让 toggle off 期间挂起的会话在挂起
// 解除瞬间被清掉。.added 不会触发 mute,.removed 会无限递归——都跳过。
registry.mutations
.compactMap { mut -> Void? in
switch mut {
case .fieldsUpdated, .stateChanged: return ()
case .added, .removed: return nil
}
}
.sink { [weak self] _ in
guard let self else { return }
let listeners = self.configStore.current.listeners
// 两个 listener 都开着时无人会被静音,跳过 activeSessions 扫描。
guard !listeners.claudeCode || !listeners.codex else { return }
self.enforceListenerMute(listeners: listeners)
}
.store(in: &configCancellables)

let router = self.router
Expand All @@ -91,7 +117,7 @@ public final class SceneRouter {

thinkingTimer.start()
decayTimer.start()
petWindowController.showAll()
petWindowController.show()
// 刘海条暂不展示,等三态/降级顶条视觉打磨完再开。controller / wiring 保留。
// notchController.show()
HopetLog.info("Hopet booted.")
Expand All @@ -110,7 +136,7 @@ public final class SceneRouter {
}

public func openPreferences() { preferencesController.show() }
public func toggleAllPets() { petWindowController.toggleAll() }
public func togglePet() { petWindowController.toggle() }

/// 把外观偏好映射到 `NSApp.appearance`:light → .aqua / dark → .darkAqua / system → nil。
/// See preferences.md §6.4.
Expand All @@ -122,26 +148,37 @@ public final class SceneRouter {
}
}

/// 仅当 listener 当前安装态与目标不一致时调用 install/uninstall,避免每次启动重写文件。
/// Codex 路径失败仅 warn,不影响 Claude 安装与启动(见 preferences.md §6.1)。
private func applyListeners(_ listeners: HopetConfig.Listeners) {
sync(.claudeCode, desired: listeners.claudeCode)
sync(.codex, desired: listeners.codex)
/// 启动时确保所有已识别 AI 工具的 hook 都已落盘。已安装则跳过(避免每次启动重写文件与
/// 堆积备份)。listener toggle 不再影响这里——toggle 只在 EventRouter 入口做静音。
/// 真正卸载 Hopet hook 走 Hooks Tab 的显式入口(v0.3 计划)。
private func ensureHooksInstalled() {
for tool in AITool.recognized {
installIfNeeded(tool)
}
}

private func sync(_ tool: AITool, desired: Bool) {
let installed = hookInstaller.isInstalled(tool)
guard installed != desired else { return }
private func installIfNeeded(_ tool: AITool) {
guard !hookInstaller.isInstalled(tool) else { return }
do {
if desired {
try hookInstaller.install(tool)
HopetLog.trace("\(tool.displayName) hooks installed.")
} else {
try hookInstaller.uninstall(tool)
HopetLog.trace("\(tool.displayName) hooks uninstalled.")
}
try hookInstaller.install(tool)
HopetLog.trace("\(tool.displayName) hooks installed at boot.")
} catch {
HopetLog.warn("\(tool.displayName) hook sync failed: \(error)")
HopetLog.warn("\(tool.displayName) hook install failed: \(error)")
}
}

/// 折中静音清扫:扫一遍 registry,把"工具 listener 关闭 + 当前无待决策"的 session
/// 整体移除。带 pending 的留着,等用户落决策后 registry.mutations 再次触发本函数
/// 把它一并清掉。幂等。`listeners` 须由调用方传入,详见 boot() 里 sink 的注释。
private func enforceListenerMute(listeners: HopetConfig.Listeners) {
let victims = registry.activeSessions.filter { !listeners[$0.tool] && $0.pendingKind == nil }
guard !victims.isEmpty else { return }
for s in victims {
HopetLog.trace("listener-mute",
"remove sid=\(s.id.hopetShortId) tool=\(s.tool.rawValue) state=\(s.currentState.rawValue)")
permissionPrompter.cancelPending(sessionId: s.id)
registry.remove(s.id)
router.purgeTranscriptMaps(for: s.id)
}
}
}
25 changes: 24 additions & 1 deletion Sources/Hopet/Core/HopetConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,37 @@ public struct HopetConfig: Codable, Equatable, Sendable {
case light, dark, system
}

/// Listener 软开关(折中静音)。toggle 不动 hook 文件——hooks 启动时已落盘,
/// off 只让 EventRouter 静默丢事件、SceneRouter 清掉无待决策的气泡;带 pending
/// 的气泡保留到用户落决策后再清。彻底卸载走 Hooks Tab(v0.3 计划)。
/// See preferences.md §11.6.
public struct Listeners: Codable, Equatable, Sendable {
public var claudeCode: Bool
public var codex: Bool

public init(claudeCode: Bool = true, codex: Bool = false) {
public init(claudeCode: Bool = true, codex: Bool = true) {
self.claudeCode = claudeCode
self.codex = codex
}

/// `.custom` 默认视为开启:与 EventRouter 兜底一致——v0.1 不识别的工具
/// 不挂软静音,避免因未来扩展工具静默丢事件。Hooks Tab 没有它的 toggle。
public subscript(tool: AITool) -> Bool {
get {
switch tool {
case .claudeCode: return claudeCode
case .codex: return codex
case .custom: return true
}
}
set {
switch tool {
case .claudeCode: claudeCode = newValue
case .codex: codex = newValue
case .custom: break
}
}
}
}
}

Expand Down
19 changes: 9 additions & 10 deletions Sources/Hopet/Core/PetAggregator.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import Foundation
import Combine

/// 把多 session → 单宠物的优先级聚合,权威算法见 hooks-and-priority.md §3.2。
/// 把多 session → 单宠物的优先级聚合(全局唯一宠物,跨所有 AI 工具)。
/// 权威算法见 hooks-and-priority.md §3.2。
@MainActor
public final class PetAggregator {
private unowned let registry: SessionRegistry
Expand All @@ -14,22 +15,20 @@ public final class PetAggregator {
.sink { [weak self] mutation in
guard let self else { return }
switch mutation {
case .added(_, let tool),
.removed(_, let tool),
.stateChanged(_, let tool, _, _):
self.recompute(tool: tool)
case .added, .removed, .stateChanged:
self.recompute()
case .fieldsUpdated:
break
}
}
.store(in: &bag)

for tool in registry.pets.keys { recompute(tool: tool) }
recompute()
}

public func recompute(tool: AITool) {
guard let current = registry.pets[tool] else { return }
let active = registry.activeSessions(of: tool)
public func recompute() {
let current = registry.pet
let active = registry.activeSessions
let newState: PetState
let newDriver: String?
if active.isEmpty {
Expand All @@ -47,7 +46,7 @@ public final class PetAggregator {
newDriver = leader.id
}
guard current.aggregatedState != newState || current.drivenBySessionId != newDriver else { return }
registry.updatePet(tool) {
registry.updatePet {
$0.aggregatedState = newState
$0.drivenBySessionId = newDriver
}
Expand Down
34 changes: 15 additions & 19 deletions Sources/Hopet/Core/SessionRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import Combine
@MainActor
public final class SessionRegistry: ObservableObject {
@Published public private(set) var sessions: [String: Session] = [:]
@Published public private(set) var pets: [AITool: PetInstance] = [:]
/// 全局唯一宠物(v0.x 起:所有 AI 工具共用一只)。
@Published public private(set) var pet: PetInstance

/// 「单 session 状态变化 / 加入 / 移除」的细粒度变更广播。
public let mutations = PassthroughSubject<Mutation, Never>()
Expand All @@ -17,24 +18,13 @@ public final class SessionRegistry: ObservableObject {
case fieldsUpdated(sessionId: String)
}

/// v0.1:只为 Claude 创建宠物实例。Codex 留到 v0.2,那时 hooks 也成熟。
public static let activeTools: [AITool] = [.claudeCode]

public init() {
for tool in Self.activeTools {
pets[tool] = PetInstance(tool: tool, screenPosition: defaultPosition(for: tool))
}
self.pet = PetInstance(screenPosition: Self.defaultPosition())
}

private func defaultPosition(for tool: AITool) -> CGPoint {
// 主屏右下,Codex 再向左偏移 200。具体 frame 在 PetWindow 创建时再 clamp。
let baseX: CGFloat = 1400
let baseY: CGFloat = 200
switch tool {
case .claudeCode: return CGPoint(x: baseX, y: baseY)
case .codex: return CGPoint(x: baseX - 200, y: baseY)
case .custom: return CGPoint(x: baseX - 400, y: baseY)
}
/// 全局宠物默认位置。后续若引入位置持久化,会读 ConfigStore。
private static func defaultPosition() -> CGPoint {
CGPoint(x: 1400, y: 200)
}

// MARK: - Mutations
Expand Down Expand Up @@ -79,17 +69,23 @@ public final class SessionRegistry: ObservableObject {
mutations.send(.fieldsUpdated(sessionId: sessionId))
}

/// 同工具的活跃 session。`EventRouter.pruneStaleSiblings` 等仍按 tool 维度判定"同一终端的躺尸"。
public func activeSessions(of tool: AITool) -> [Session] {
sessions.values.filter { $0.tool == tool }
}

/// 所有活跃 session(跨 tool)。PetAggregator / PetStageView 消费此列表。
public var activeSessions: [Session] {
Array(sessions.values)
}

public func session(_ id: String) -> Session? { sessions[id] }

// MARK: - Pet aggregation hook

public func updatePet(_ tool: AITool, mutate: (inout PetInstance) -> Void) {
guard var p = pets[tool] else { return }
public func updatePet(_ mutate: (inout PetInstance) -> Void) {
var p = pet
mutate(&p)
pets[tool] = p
pet = p
}
}
2 changes: 1 addition & 1 deletion Sources/Hopet/HookKit/HookDoctor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public enum HookDoctor {

// Tools
lines.append("Claude hooks installed: \(installer.isInstalled(.claudeCode) ? "yes" : "no")")
lines.append("Codex notify installed: \(installer.isInstalled(.codex) ? "yes" : "no")")
lines.append("Codex hooks installed: \(installer.isInstalled(.codex) ? "yes" : "no")")

return lines.joined(separator: "\n")
}
Expand Down
Loading
Loading