版本 v0.2 · 初版 2026-06-14,开源化修订 2026-08-20 · 配套
docs/PRD.md与design/mockups.html本文是工程层的最高依据。产品方向以 PRD 为准,视觉以 mockups 为准,架构与实现以本文为准。 现状核对:Swift 6 与 XcodeGen 工程已建立并按本文分层运行。本文同时包含现行架构与后续规划,当前已实现的能力见docs/FEATURES.md。
核心思路是把核心逻辑层,即模型、状态机、适配器与集成层,抽成一个本地 Swift Package。该包为纯逻辑、可单测、不依赖 NSApplication,App target 只做窗口装配与生命周期。这样安排的好处是:
- 逻辑层可在无 UI 的情况下跑单元测试与解析器基准样本回归;
- 业务逻辑与系统副作用解耦,沙盒能力差异只在"集成桥"和入口处经
Capabilities区分; - 新增 Agent = 在
OpsnookAdapters里加一个目录 + 注册,不触碰 UI。
Opsnook/ # 仓库根
├─ project.yml # XcodeGen 工程描述(工程文件不入库,避免合并冲突)
├─ Opsnook.xcodeproj # 由 project.yml 生成(gitignore)
├─ App/ # App target(仅窗口/生命周期/装配)
│ ├─ OpsnookApp.swift # @main,NSApplicationDelegateAdaptor
│ ├─ AppDelegate.swift # 启动、菜单栏代理 App(LSUIElement)、热键注册
│ ├─ Composition/
│ │ └─ AppContainer.swift # 依赖装配(把 Store/Adapters/Integrations 接线)
│ ├─ Windows/
│ │ ├─ NotchWindowManager.swift # 多显示器:每屏一个刘海窗口,插拔/改分辨率对账
│ │ ├─ NotchWindowController.swift # 刘海常驻 NSPanel(绑定单块屏幕),收起与展开同一窗口
│ │ ├─ TimelineWindowController.swift # 会话时间轴独立窗口
│ │ ├─ GlobalHotKey.swift # ⌥⌘H 全局热键(Carbon RegisterEventHotKey)
│ │ └─ ScreenGeometry.swift # safeAreaInsets / 菜单栏高度 / displayID 解析
│ ├─ Resources/
│ │ ├─ Localizable.xcstrings # String Catalog(zh-Hans + en)
│ │ ├─ Assets.xcassets # 各 Agent 真实 logo、App 图标、品牌色
│ │ └─ Info.plist
│ ├─ Entitlements/
│ │ └─ Opsnook.AppStore.entitlements # App Sandbox 开启 + 最小权限(用户授权目录读写 + app-scope 书签)
│ └─ Config/
│ ├─ Base.xcconfig
│ └─ AppStore.xcconfig # App Store 沙盒渠道编译设置(单一渠道)
│
├─ Packages/
│ └─ OpsnookKit/ # 本地 SPM 包(核心逻辑层,平台=macOS)
│ ├─ Package.swift
│ ├─ Sources/
│ ├─ OpsnookCore/ # 模型 + 状态机 + 状态仓库(零 OS 副作用)
│ │ ├─ Models/ # AgentSession / AgentStatus / AgentEvent / Approval ...
│ │ ├─ State/ # SessionReducer / AggregateBuilder / UsageAggregator / EngineConfig
│ │ ├─ Store/ # SessionStore(actor)+ 发布器
│ │ ├─ Capability/ # Capabilities(预设 .appStore)能力开关
│ │ ├─ Layout/ # NotchLayout,刘海几何计算(与 AppKit 解耦)
│ │ ├─ Settings/ # AppLanguageController,语言偏好
│ │ └─ Util/ # TimeSource(可注入时钟)、ActivityText
│ ├─ OpsnookAdapters/ # 适配器协议 + 各 Agent 适配器
│ │ ├─ AgentAdapter.swift # 协议
│ │ ├─ AdapterRegistry.swift # 注册表 + 并发检测编排(注册调用点在 AppContainer)
│ │ ├─ AgentCatalog.swift # 品牌与 kind 的唯一真相源
│ │ ├─ GitHead / AdapterLimits / SessionFileFingerprint / ShellCommandClassifier
│ │ │ # 跨适配器共享件,见 AGENTS.md 4.1 节
│ │ ├─ ClaudeCode/ Codex/ Antigravity/ ZCode/ WorkBuddy/ Trae/ Qoder/
│ │ ├─ Gemini/ # 已下线,代码保留但不注册,见 AGENT_INTEGRATION_PLAN.md
│ │ └─ Usage/ # UsageReader,token 与成本读取
│ ├─ OpsnookIntegration/ # OS 副作用桥(全部藏在协议后,便于 mock/分发差异)
│ │ ├─ FileAccess/ # SecurityScopedBookmarkStore / AgentDirectoryAccess
│ │ ├─ Focus/ # TerminalFocuser / ApplicationFocuser(沙盒下为 NSWorkspace App 级激活)
│ │ ├─ Hooks/ # ClaudeHooksInstaller + 本地 IPC 审批服务
│ │ ├─ Notifications/ # NotificationCoordinator(刘海脉冲为主,UN 通知可选)
│ │ ├─ Workspace/ # GitWorkspaceService(本地 Git 状态与显式操作)
│ │ └─ LaunchAtLogin/ # LaunchAtLoginController(SMAppService)
│ └─ OpsnookUI/ # SwiftUI 视图 + 设计系统(依赖 OpsnookCore 只读视图模型)
│ ├─ DesignSystem/ # 颜色/字体/间距 token、波形、脉冲、ReduceMotion
│ ├─ Panel/ # 主面板、分组列表、审批卡片,以及活动/用量/工作区各页视图
│ ├─ Notch/ # 收起态左右翼视图
│ ├─ Timeline/ # 会话时间轴泳道视图与导出
│ ├─ Brand/ Localization/ Resources/
│ └─ ViewModels/ # 订阅 Store → 可观察状态(@Observable)
│ └─ Tests/ # SPM 测试须在包内(不可放仓库根)
│ ├─ OpsnookCoreTests/ # 状态机、聚合、排序(确定性时钟)
│ ├─ OpsnookAdaptersTests/ # 各 Agent 解析器,样本随测试内联
│ ├─ OpsnookIntegrationTests/ # 桥接层用 mock
│ └─ OpsnookUITests/ # 视图模型、布局与本地化 key
│
├─ docs/ design/ images/ # 已有
└─ .github/workflows/ci.yml # 单元测试 + App Store 渠道编译
为什么使用 XcodeGen 而不提交 .xcodeproj:.xcodeproj 是体量很大的 XML 文件,多人协作与多分支开发时必然产生冲突,长期维护成本高。project.yml 是可读、可 diff 的声明文件,更符合长期维护优先的取向。备选方案是 Tuist,功能更强但也更重。若倾向于最简单、不引入额外工具的方案,也可以改为提交 .xcodeproj,这是一个明确的取舍点。
放在 OpsnookCore/Models。设计原则是:6 种状态使用扁平枚举,便于 Equatable 比较、排序与 UI diff;状态细节作为可选载荷挂在 session 上,而不把关联值塞进状态枚举,以免 Combine 去重与 SwiftUI diff 变得复杂。
// MARK: - 身份与静态元数据
/// 一类 Agent 的稳定标识(客户端与 CLI 是两个 kind,但共享 brand)。
public struct AgentKind: Hashable, Codable, Sendable {
public let id: String // "claude-code" / "claude-code-cli" / "codex" / "codex-cli"
public let displayName: String // "Claude Code" / "Codex CLI"(不本地化)
public let brand: AgentBrand // 共享图标与品牌色
}
public struct AgentBrand: Hashable, Codable, Sendable {
public let id: String // "claude" / "codex" / "gemini" ...
public let colorHex: String // "#D97757"
public let iconAsset: String // Assets 名,真实 logo
}
public enum CapabilityLevel: Int, Comparable, Codable, Sendable {
case l1 = 1, l2 = 2, l3 = 3
public static func < (a: Self, b: Self) -> Bool { a.rawValue < b.rawValue }
}
/// 适配器声明的静态能力(与具体会话无关)。
public struct AgentDescriptor: Sendable {
public let kind: AgentKind
public let maxCapability: CapabilityLevel
public let supportsApproval: Bool // 是否可面板内批准/拒绝
public let supportsTerminate: Bool
public let detectionMethod: DetectionMethod // .desktopClient / .logWatch / .processScan / .hooks
}
// MARK: - 6 种归一化状态(整个产品的语义基础)
public enum AgentStatus: Int, CaseIterable, Comparable, Codable, Sendable {
case needsApproval = 1 // 琥珀 #FFB340 脉冲
case waitingInput = 2 // 天蓝 #64D2FF 脉冲
case error = 3 // 红 #FF5F57 静态红点
case running = 4 // 绿 #30D158 波形
case done = 5 // 灰绿对勾,一次性轻提示后衰减
case idle = 6 // 灰 #5A5A5E 暗点
public var sortWeight: Int { rawValue } // 越小越靠前
public static func < (a: Self, b: Self) -> Bool { a.rawValue < b.rawValue }
}
// MARK: - 会话(UI 的唯一数据源单元)
public struct SessionID: Hashable, Codable, Sendable { public let raw: String }
public struct AgentSession: Identifiable, Equatable, Sendable {
public let id: SessionID
public let agent: AgentKind
public var status: AgentStatus
public var achievedCapability: CapabilityLevel // 该会话实际达到的等级(可能因降级低于 max)
// L2 元数据,未知时为 nil。均为 Agent 产出内容,不翻译
public var taskTitle: String? // "重构用户鉴权模块,迁移到 JWT"
public var projectPath: URL?
public var projectName: String? // "opsnook-app"
public var gitBranch: String? // "feat/auth"
public var gitState: GitState? // clean / dirty / changedFiles(n)
// 状态细节
public var pendingApproval: Approval? // status == .needsApproval 时存在(L3)
public var errorInfo: ErrorInfo?
// 计时(驱动"已等待 1m32s" / "运行中 2m04s" / "5 分钟前")
public var startedAt: Date?
public var statusChangedAt: Date
public var completedAt: Date?
// 路由
public var focusTarget: FocusTarget? // 回跳定位(pid / tty / windowTitle / bundleID)
public var sessionResourceURL: URL? // "复制会话路径"用
}
public enum GitState: Equatable, Sendable {
case clean, dirty, changedFiles(Int) // "clean" / "dirty" / "3 files"
}
public struct Approval: Identifiable, Equatable, Sendable {
public let id: ApprovalID // 关联被阻塞的 hook/IPC 请求
public let toolName: String // "Bash"
public let command: String // "npm install jsonwebtoken"
public let cwd: URL? // "~/dev/opsnook-app"
public let detail: String? // "将写入 node_modules"
public let requestedAt: Date
public let channel: ApprovalChannel // 决策回传通道(适配器内部用)
}
public struct ApprovalID: Hashable, Codable, Sendable { public let raw: String }
public enum ApprovalDecision: Sendable { case allow, deny }
public struct ErrorInfo: Equatable, Sendable {
public let summary: String? // 简短原因(如 "测试未通过");不上传、不展开堆栈
public let occurredAt: Date
}
public enum FocusTarget: Equatable, Sendable {
case terminalWindow(app: String, tty: String?, windowTitle: String?) // iTerm/Terminal/Warp
case bundleApp(bundleID: String) // 降级:仅把 App 激活到前台
case vscodeIntegratedTerminal(workspace: URL?)
}
// MARK: - 事件流(活动 Tab + 通知 + 状态机输入)
public struct AgentEvent: Identifiable, Equatable, Sendable {
public let id: EventID
public let timestamp: Date
public let agent: AgentKind
public let sessionID: SessionID?
public let kind: EventKind
public let title: String? // Agent 产出摘要(不翻译)
public let projectName: String?
public let gitBranch: String?
public let command: String? // 审批类事件携带
}
public enum EventKind: String, Codable, Sendable {
case sessionStarted, completed, errored
case approvalRequested, userApproved, userDenied, userTerminated
}
// MARK: - 聚合(收起态刘海)
public struct AggregateSnapshot: Equatable, Sendable {
public let highestPriority: AgentStatus? // 右翼:全局最高优先级
public let runningCount: Int
public let pendingApprovalCount: Int
public let errorCount: Int
public let leftWingIcons: [AgentKind] // ≤4,按权重排序
public let overflowCount: Int // +N
}事件与会话的关系:会话(AgentSession)表示当前的快照,事件(AgentEvent)表示已经发生的事实。会话页订阅会话集合,活动页订阅事件流,事件在本地保留 7 天。两者同源,状态机在产生状态跃迁时一并 emit 事件。
放在 OpsnookAdapters。每个适配器是一个 actor,负责把某个 Agent 的原始数据翻译成统一的 AdapterEmission 流。Store 只消费 emission,不关心其来源。
PRD 第 8 条要求的九项能力,全部落到协议上:
public protocol AgentAdapter: Actor {
/// 静态元数据(检测方式、能力上限、审批/回跳能力)。
nonisolated var descriptor: AgentDescriptor { get }
// ① 检测方式
func detect() async -> DetectionResult
// .installed(version:) / .notInstalled / .needsSetup(reason:)
// 接入准备,可逆。例如写入 Claude hooks,返回回执用于还原
func prepareIntegration() async throws -> IntegrationReceipt
func revertIntegration(_ receipt: IntegrationReceipt) async throws
// ②③④⑤⑥ 会话发现 + 状态解析 + 任务标题 + 项目路径 + git 分支
// 合并为一条实时流:适配器内部 fswatch/轮询,产出归一化快照与事件。
func start(context: AdapterContext) async -> AsyncStream<AdapterEmission>
func stop() async
// ⑦ 审批能力(L3);不支持则 descriptor.supportsApproval == false
func respond(to approval: ApprovalID, decision: ApprovalDecision) async throws
func terminate(session: SessionID) async throws
// ⑧ 回跳能力:解析该会话对应的回跳目标
func focusTarget(for session: SessionID) async -> FocusTarget?
// ⑨ 降级能力:运行期上报健康/能力降级(如失去文件访问 → L3 降 L1)
// 通过 emission 中的 .healthChanged 表达,无需单独方法。
}
/// 适配器产出的归一化事件(Store 负责 reduce)。
public enum AdapterEmission: Sendable {
case sessionUpserted(SessionSnapshot) // 部分/完整快照,Store 合并
case sessionRemoved(SessionID)
case event(AgentEvent)
case approvalRequested(Approval, SessionID)
case approvalResolved(ApprovalID) // 被外部(终端里)处理掉了
case healthChanged(AdapterHealth) // .ok / .degraded(to: CapabilityLevel, reason:) / .lostAccess
}
/// 适配器解析出多少就给多少,Store 负责补齐计时等派生字段。
public struct SessionSnapshot: Sendable {
public let id: SessionID
public let agent: AgentKind
public let status: AgentStatus
public let capability: CapabilityLevel
public var taskTitle: String?
public var projectPath: URL?
public var gitBranch: String?
public var gitState: GitState?
public var pendingApproval: Approval?
public var errorInfo: ErrorInfo?
public var focusTarget: FocusTarget?
public var sessionResourceURL: URL?
public var observedAt: Date
}
/// 注入给适配器的依赖:文件访问、git、进程、时钟。便于处理沙盒差异与测试时的 mock。
public struct AdapterContext: Sendable {
public let fileAccess: FileAccessProviding // 沙盒下走 security-scoped bookmark
public let git: GitInfoProviding
public let processes: ProcessScanning
public let clock: any Clock<Duration>
public let capabilities: Capabilities
}三层降级在协议层的体现:
- 原生集成(L3):适配器内部使用官方扩展点,例如 Claude hooks,
approvalRequested走 IPC 阻塞通道。 - 日志监听(L2):只有
sessionUpserted,没有 approval,detectionMethod取.logWatch。 - 通用包装器或进程扫描(L1):只产出 running 与 idle,无元数据,
emission退化为最小集合。
运行期若文件访问丢失或解析失败,适配器 emit .healthChanged(.degraded(to: .l1, ...)),UI 据此显示降级态。适配器不得崩溃:任何解析错误都应降级处理,不向 Store 抛出。
注册与发现:AdapterRegistry 持有所有已注册的适配器类型,启动时并发执行 detect(),把已安装的纳入运行,把已安装但未接入的交给接入向导,把未检测到的在设置页置灰。新增 Agent 需要做的是:实现协议、在 registry 中注册、添加 Assets、添加 String Catalog 文案。
该结论已随 Antigravity CLI 的接入而更新。以下内容保留为历史判断依据,针对的是 Antigravity 的桌面 Electron 存储。当时的决策是不把 Antigravity 作为独立 agent 适配,维持过滤现状。Codex 与 Claude 能够适配的前提,是各自有干净、独立、可追加的会话 JSONL 日志,即 ~/.codex/sessions/*.jsonl 与 ~/.claude/projects/**/*.jsonl,而当时的 Antigravity 没有等价物:
~/.gemini/tmp/*/chats/*a2a-serv.jsonl是空桩。Antigravity 在 Gemini 目录下写的是 A2A 桥接会话,实测全部仅含 0 到 1 条<session_context>…This is the Gemini CLI…设置消息,没有对话、标题与项目信息,且全部共享同一个sessionId:"a2a-server",无法作为唯一键。GeminiSessionParser用sessionID != "a2a-server"明确过滤。适配这一路径只会产出大量无意义的空会话并显示为运行中。- 真实对话不在可稳定解析的本地文件中。
~/Library/Application Support/Antigravity/是 VS Code 衍生的 Electron 存储:globalStorage/state.vscdb中的 key 全是antigravity.notification.*与 UI 状态,且为旧档;近期写入只发生在 Chromium 的 Session Storage 与 Local Storage 对应的 LevelDB,以及Cache_Data等二进制缓存中。真实的 prompt 与状态要么埋在未公开的二进制 LevelDB 中,随版本更新即失效,要么位于 Google 服务端。适配这类存储等同于逆向工程,不属于新增解析器的范畴。 - 回跳同样受限。Antigravity 是 GUI Electron 应用,与 Claude 和 Codex 桌面端情况相同,最多用
.bundleApp激活应用,无法精确定位到具体对话。
重新评估的触发条件是:Antigravity 提供类似 Codex 与 Claude 的干净本地会话日志,或决定投入对其 Electron 与 LevelDB 存储的逆向工作。该条件已由 Antigravity CLI 满足,CLI 在 ~/.gemini/antigravity-cli/brain 下写入结构化 transcript,现已作为一等 agent 适配,详见 docs/sessions_status_logic/antigravity-cli.md。桌面 Electron 存储仍不适配。
ZCode(Z.ai,Electron 桌面客户端加 zcode-cli)有结构化、可解析的本地存储,已作为一等 agent 适配,对应 ZCodeAdapter。
- 数据源:只读轮询
~/.zcode/cli/db/db.sqlite的session、message、part表。该库是桌面与 CLI 共写的逐轮实时库,会话 id 形如sess_<uuid>。读取经ZCodeSessionStore完成,使用import SQLite3与系统 JSON1 的json_extract解析 data 列,以只读方式打开并在 WAL 锁定时拷贝快照兜底,不向外抛出异常。- 不使用
v2/tasks-index.sqlite:实测其title是冻结的会话标题,updated_at与task_status不随每轮更新,采用它会导致标题陈旧且活动页不刷新。cli/db才是逐轮实时源。
- 不使用
- 字段映射:
session.title对应面板标题;最近一条用户消息文本(part)对应活动页的latestUserMessage;session.directory对应项目与 Git 分支;最后活动时间取该会话消息的最大time_created,用于活跃窗口判定。 - 状态推导,对应
ZCodeStatusMap,依据最新一条消息:assistant 带finish判为 done,错误类 finish 判为 error;assistant 无 finish 时,新鲜判为 running、陈旧判为 idle;user 消息新鲜时判为 running。 - 能力与回跳:能力等级为 L2,不支持审批。focus 取
.bundleApp("dev.zcode.app"),对 GUI 应用只能激活,无法精确定位具体会话,与 Claude 和 Codex 桌面端相同。 - 后续增量:实现审批(L3)需要读取
permission表;耗时短于轮询间隔的回合可能漏掉 running 态,后续可缩短轮询间隔或增加内容变更事件。
工作区页是会话相关仓库的本地 Git 状态视图,不是完整的 Git 客户端。它只从当前与近期 AgentSession.projectPath 所属的仓库发现入口,不扫描全盘,也不自动修改工作树。
- 模型层(
OpsnookCore):WorkspaceSnapshot、WorkspaceRepositorySnapshot、WorkspaceFileChange、WorkspaceDiffLine、WorkspaceCommit分别描述仓库快照、文件改动、inline diff、commit 时间线、ahead 与 behind、Agent 关联与错误态。模型不依赖 AppKit 与 SwiftUI,可在集成测试中用临时 Git 仓库构造。 - 集成层(
OpsnookIntegration/Workspace):GitWorkspaceService是 actor,所有 Git 调用经Process.arguments传参执行,禁止 shell 拼接。git 可执行文件由resolveGitExecutable()选择,优先使用随包内置的真实 git,其次是开发机上 Command Line Tools 或 Xcode 内的 git,原因见docs/SANDBOX.md第 5 节。该服务负责rev-parse --show-toplevel去重、在运行期记住最近观察到的仓库、读取 branch 与 upstream 与 status 与 diff 与 log,并暴露 stage、unstage、stageAll、unstageAll、commit、push、revert 等显式操作。 - UI 层(
OpsnookUI):WorkspaceModel只持有 UI 状态与 async 操作闭包;WorkspaceView放在工作区专用展开面板内,提供仓库选择器、Agent 过滤、冲突提示条、staged 与 unstaged 分组、commit 编辑区、commit 时间线与 inline diff 展开。 - 布局例外:普通面板仍使用
OpsnookMetrics.panelWidth = 420;工作区页按 Git 高保真稿使用OpsnookMetrics.workspacePanelWidth = 582,由PanelTab.panelWidth与NotchWindowController统一接线,以免影响会话、活动、用量与设置页。 - App 装配层:
AppContainer持有GitWorkspaceService,把读取与写入能力注入WorkspaceModel;NotchRootView在切换到.workspace、会话发生变化或 Git 操作完成后刷新快照;窗口控制器监听 workspace 状态变化并重算高度。 - 沙盒下的运行条件:工作区页在沙盒中可用,前提是用户已授权工作区根目录,授权固化为 security-scoped bookmark,git 子进程继承本 App 已激活的安全作用域访问。未授权时
GitWorkspaceService返回.needsAuthorization,UI 展示授权引导。若三个 git 候选路径均不可执行,则展示不可用降级态。这些差异不得在 UI 或服务内以#if表达。 - 隐私边界:所有 Git 数据只在本机解析,commit message、diff、路径与日志都不上传。写操作只响应用户点击,不自动执行 stage、commit 或 push。untracked 文件的回滚不做静默删除,v1 禁用该操作并给出本地错误提示;已跟踪文件的回滚必须二次确认。
把单个会话的全过程可视化为泳道时间轴,区别于第 2 节状态引擎只关注末态。时间轴由独立的 NSWindow 承载,入口是会话行悬停时浮现的 Timeline 按钮。分层与既有架构一致:
- 模型层(
OpsnookCore/Models/SessionTimeline):TimelineEvent由 7 类 kind、5 种 status、facts 键值与原始 JSONL 行构成;TimelineLane表示泳道,含spawnEventID与mergeEventID两个分叉汇入链接;SessionTimeline是容器。三者都是纯值类型,不向外泄漏 JSON 字典。 - 解析层(
OpsnookAdapters):与状态解析器并列,两者不复用同一套状态机。ClaudeTimelineParser:tool_use按工具名分型,Edit 与 Write 归为文件修改,Bash 归为命令,Task 与 Agent 归为 subagent,其余归为工具调用;tool_result按tool_use_id配对,回填状态、耗时与输出;模型输出归并进触发它的人工输入卡。subagent 对应同目录下的agent-<id>.jsonl,归属关系由首行sessionId预检确定。老会话的主文件可能只剩summary行,此时时间轴为空,由 UI 空态兜底。CodexTimelineParser:function_call与custom_tool_call按call_id与 output 配对,从Process exited with code N提取退出码,从apply_patch头部提取文件清单。subagent 对应spawn_agent,其 output 携带agent_id与nickname,子代理是文件名以 agent_id 结尾的独立 rollout 文件,close_agent卡是汇入点。
- UI 层(
OpsnookUI/Timeline):SessionTimelineModel的layout计算属性是布局的唯一入口。关键规则是:同泳道中连续且同类的工具事件只占一个轴槽,其余垂直堆叠,横版首泳道向上堆叠、其余向下,以避免穿越泳道,堆叠上限为 8 个,超出后另起一槽;支持组与轮两级收起,收起的事件不渲染也不占槽,被选中时自动展开;泳道的分叉点与汇入点钉在主轴上,不参与堆叠;分叉与汇入采用 git 图风格的肘形走线,即垂直落道、沿道行进、直角拐回,以避免对角线穿过卡片造成交叉。 - App 层:
TimelineWindowManager为每个会话维护一扇窗口,重复打开时前置复用;AppContainer.loadTimeline按AgentCatalog品牌分发解析器,在后台线程完成解析。入口链为PanelRow的 Timeline 按钮到PanelView,再到NotchRootView、NotchWindowController与AppDelegate。支持范围由SessionTimelineSupport.canOpen统一收口,条件是已支持的品牌加.jsonl资源。 - 注意:独立窗口不经过
NotchRootView,语言必须直接读取AppLanguageController.shared.locale,因为根视图对自身 body 注入的\.locale只对子视图生效。隐私边界与工作区页一致,仅在本地解析,不产生新数据,也不上传。
放在 OpsnookCore/State。核心是一个纯函数 reducer,签名为 reduce(session, emission, now) -> (AgentSession, [AgentEvent]),再加上由确定性 Clock 驱动的定时衰减,负责 done 到 idle 的跃迁与等待计时。由于是纯函数,该部分可完全单测。
| 当前 | 触发 | 目标 | 派生事件 |
|---|---|---|---|
| idle / (新) | 检测到活动 | running | sessionStarted |
| running | 收到审批请求 | needs_approval | approvalRequested |
| needs_approval | 用户/外部允许 | running | userApproved |
| needs_approval | 用户/外部拒绝 | running / idle | userDenied |
| running | Agent 提问 | waiting_input | — |
| waiting_input | 收到输入 / 继续 | running | — |
| running | 会话成功结束 | done | completed |
| running / 任意 | 解析到错误 | error | errored |
| done | 衰减(N 秒后) | idle | — |
| error | 衰减(M 秒后,可配) | idle | — |
| 任意 | 会话消失 / 超时 | idle | — |
| running / waiting | 用户终止 | idle | userTerminated |
衰减时长建议:
done→idle默认 8s(对应设计稿"短暂绿色闪烁后归于平静");error不自动衰减,保留到用户查看或会话被新活动覆盖(出错是要被看见的)。这些进EngineConfig,便于调。
适配器(actor) ──AdapterEmission──▶ SessionStore(actor)
│ 1. SessionReducer 应用跃迁,产出事件
│ 2. TimerScheduler 处理衰减/计时(Clock 驱动)
│ 3. AggregateBuilder 计算收起态聚合
│ 4. EventLog 落 7 天本地事件(活动页)
▼
发布:sessions / aggregate / events
(Combine @Published 或 AsyncStream)
▼
OpsnookUI ViewModel(@Observable)只读订阅
并发模型:每个适配器一个 actor,用于隔离各 Agent 的解析;SessionStore 是一个 actor,作为唯一真相源串行化所有写入,以杜绝竞态;UI 在主线程订阅快照。这是 PRD 第 10 节所述模型的具体落地。
排序规则,同时适用于面板与收起态:先按 status.sortWeight 排序,同一状态内按 statusChangedAt 降序排列,即最近变化的靠前。收起态右翼取全局 min(status);左翼取处于活跃状态(running、waiting、approval)的会话的 agent 图标,最多 4 个,超出部分折叠为 +N。
计时显示不进入状态,否则每秒都会触发 diff。计时由 UI 层根据 statusChangedAt 与一个 1 秒周期的 TimelineView 或 Timer 自行渲染,Store 只存时间戳。
这是本项目最大的结构性约束。概括而言,沙盒会显著削弱读取其他工具私有目录、控制其他窗口、改写其他工具配置这三类核心能力。本项目只有 App Store 沙盒这一个渠道,原 Direct 非沙盒渠道已移除,因此每类核心能力都必须有可接受的沙盒降级路径,并统一经运行期 Capabilities(预设 .appStore)表达,业务层不散落 #if。
| 能力 | 非沙盒下的天然形态(参考) | 沙盒下的实现 | 降级方案 |
|---|---|---|---|
读 ~/.codex/sessions 等会话 JSONL |
直接读取 | 需要 security-scoped bookmark,由用户在向导中授权该文件夹 | 用户授权后用书签持久访问;未授权时该 Agent 停留在未授权状态 |
读 ~/.claude/projects JSONL |
直接读取 | 同上,依赖书签 | 与上一行相同 |
写 Claude Code hooks(~/.claude/settings.json) |
直接写入,可还原 | 需要对该文件有书签写权限,审核较为敏感 | 提供复制配置片段、由用户手动粘贴的合规兜底路径 |
| 用辅助功能 API 精确回跳到具体终端窗口或标签 | 可用,AXUIElement 功能完整 | 不可用。App Review Guideline 2.4.5 禁止将辅助功能 API 用于非无障碍用途 | 降级为经 NSWorkspace 把对应 App 激活到前台,精度为 App 级而非窗口级 |
| 用 AppleScript 控制 iTerm 或 Terminal | 可用,需用户授权自动化 | 需要 apple-events 权限与 TCC 授权,审核风险较高 | 默认关闭,走 NSWorkspace 降级 |
| 进程扫描 | 可用 | 受限,部分信息取不到 | 降级为基于文件或已知路径的检测 |
| PTY 包装器 | 可用 | 子进程继承沙盒,体验受限 | App Store 版不提供该入口 |
| 工作区 Git 读取 | 直接调用 /usr/bin/git |
可用,但需使用真实 git 二进制并授权工作区根目录 | 见 docs/SANDBOX.md 第 5 节的 resolveGitExecutable() |
| 开机自启 | SMAppService |
SMAppService |
两者一致 |
只有一个渠道,Capabilities 仍以结构体加可注入开关的形式表达,便于单测构造特定的能力组合。生产环境只有 .appStore 一种预设,由 AppContainer 注入一次,不存在渠道编译开关。
public struct Capabilities: Sendable, Equatable {
public let canScriptTerminals: Bool // AppleScript 控制终端
public let canWriteAgentHooks: Bool // 写 Agent hooks 配置
public let canScanProcesses: Bool // 进程与窗口扫描
public let canPTYWrap: Bool // PTY 包装器
/// App Store 沙盒版能力,当前唯一交付形态:系统级能力一律降级,
/// 终端回跳走 NSWorkspace 的 App 级激活,文件访问走 security-scoped bookmark。
public static let appStore = Capabilities(
canScriptTerminals: false,
canWriteAgentHooks: false,
canScanProcesses: false,
canPTYWrap: false
)
}业务层只查询 capabilities.xxx,UI 与适配器中不得出现散落的 #if。文件访问差异收敛在 OpsnookIntegration/FileAccess,由 AgentDirectoryAccess 与 security-scoped bookmark 承担;其余能力降级收敛在 OpsnookIntegration 的桥接实现与 Capabilities 中。
不使用辅助功能 API 是一项明确决定,而非临时降级。App Review Guideline 2.4.5 禁止把辅助功能 API 用于非无障碍用途,窗口回跳属于此列。相关代码已物理删除,不保留开关。
在 App Store 沙盒中主动扫描 ~/.claude 这类拉取方式,既需要书签,也在审核直觉上存在争议。另一种方式是由 Opsnook 安装一个 hook 脚本,让运行在沙盒外的 Agent 在事件发生时把状态 JSON 写进 Opsnook 可读的容器位置,Opsnook 只读自己的容器,不需要额外权限。当前实现采用两者结合:会话状态经用户授权的目录读取,Claude 的审批请求经 hook 写入 App 容器后由 Opsnook 读取并回传决策。
每个里程碑给出交付物与验收标准,颗粒度到模块或文件级,以确保每一步都是最小可验证增量。
- T1 工程脚手架:XcodeGen
project.yml、App Store 渠道的 xcconfig 与 entitlements、OpsnookKit本地包、CI(单元测试加渠道编译)。 - T2
OpsnookCore模型(第 2 节全部结构)+ 单测。 - T3 状态机:
SessionReducer(跃迁表)+SessionStoreactor +AggregateBuilder+ 确定性 Clock + 单测(覆盖每条跃迁与衰减)。 - T4 适配器框架:
AgentAdapter协议、AdapterRegistry、Common(FSEventsWatcher、带字节偏移的JSONLTailer、GitInfo、ProcessScanner桩)。 - T5
CodexAdapter:检测、会话发现、状态/标题/项目/分支解析、focusTarget;基准样本。 - T6
Capabilities(预设.appStore)骨架。 - T7 基础 UI 壳:
NotchWindowController(非激活 NSPanel、statusBar 层、收起态高度严格 ≤ 菜单栏底边)、ScreenGeometry、展开 420pt SwiftUI 面板渲染最小分组列表(绑定 Store)、设计 token、ReduceMotion。 - T8 App 生命周期:LSUIElement 代理 App、⌥⌘H 热键、依赖装配。
- 验收:App 启动后,真实 Codex 会话状态在 2 秒内反映到刘海;悬停或按 ⌥⌘H 展开后可看到分组列表。目标是端到端打通一个适配器。
- Claude Code L3:hook 安装器 + 本地 IPC 审批服务(阻塞式决策回传)+ JSONL 解析。
- Gemini CLI L2:
~/.gemini/tmp日志监听 + 进程检测。 - 收起态最终形态(左翼微缩图标+折叠、右翼聚合规则、琥珀脉冲/静态红点)。
- 主面板:按状态分组、56pt 行高、内嵌审批卡片、悬停时的快捷操作、面板固定。
- 审批直达(F4):面板内允许/拒绝 ≤1s 回传。
- 一键回跳(F5):
TerminalFocuser(AX/AppleScript;App Store 走 NSWorkspace 降级),支持 iTerm2/Terminal/Warp/VS Code。
- 通知策略 + 专注模式(F6)。
- 自动发现 + 接入向导 + 空态引导(F7,设计稿⑥)。
- 中英双语 String Catalog 全覆盖(F9)+ 英文截断验证。
- 设置页(设计稿⑤)、权限引导。
- App Store 提审准备:沙盒、隐私清单、审核话术。
原计划中的 14 天试用与单 Agent Free 授权逻辑(StoreKit / license,对应设计稿⑦)已随开源化取消,
详见 docs/PRD.md 第 10 节。本仓库不含 Licensing 目录,也不要重新引入。
- 用量页(设计稿③):配额窗口、成本、token、7 日趋势。
- 活动 Tab(设计稿④):事件流、筛选/搜索、跳回。
- 工作区页:基于 Agent 会话仓库的本地 Git 状态、diff、stage/commit/push 与安全回滚。
- Cursor / Copilot CLI / Aider 适配器(L1/L2)。
- 多显示器(F12,2026-07-13 已交付):
NotchWindowManager每显示器一个NotchWindowController(displayID 记账,didChangeScreenParametersNotification增删/重排,mouseMoved 监视器单套分发,⌥⌘H 作用于指针所在屏);收起态按各屏自适应:有刘海的屏幕中缝宽度等于硬件刘海宽度,无刘海的屏幕由NotchLayout.collapsedCenterGap按屏宽比例模拟,取值收敛在 140 到 220pt 之间,高度取该屏菜单栏高度。 - 行内快捷操作完善(F11)。
按威胁程度与不确定性的组合排序,每条给出缓解或替代方案。
-
App Store 审核可能否决核心能力,包括读取其他工具目录、用辅助功能控制窗口、改写其他工具配置。这是最高风险项。 缓解方式:回跳降级到 App 级激活,hooks 改为由用户手动粘贴的兜底路径,目录读取经 security-scoped bookmark 与用户显式授权。当前状态:文件访问、回跳降级与 Privacy Manifest 已落地并经真机验证。首次提交曾因 2.4.5 条款被拒,相关辅助功能代码已物理删除。
-
桌面客户端事件接入是 PRD 的假设,公开 API 可能并不存在。Claude Code 与 Codex 的桌面客户端都没有公开的事件接口。 缓解方式:以 CLI 的 JSONL 与 hooks 作为可靠主路径;桌面客户端集成按尽力而为处理,不作为 L3 的前提。
-
L3 审批回传的可行性,尤其是 Codex。Claude Code 有 hook 的 stdin 决策协议,可由阻塞式 hook 加本地 IPC 实现面板内批准;而 Codex CLI 的审批是 TUI 内交互,缺少干净的程序回传通道。 处理结果:Claude Code 走 hook 加 IPC 路径并已实现;Codex 没有回传通道,已诚实降级为 L2 加一键回跳。
-
JSONL 格式跨版本漂移。各 Agent 的日志结构随版本变化,严格解析容易失效。 缓解方式:版本化 parser 加多版本基准样本,并做容错降级,即解析失败时退到 L1 而不崩溃;同时把解析层与语义层分离。
-
刘海窗口与菜单栏几何,涉及收起态严格不越界、展开时越界、外接屏吸附、刘海机型的 safe area。这类问题容易出现像素级 bug 且机型差异较大。 缓解方式:
ScreenGeometry统一使用safeAreaInsets与auxiliaryTop{Left,Right}Area(macOS 12 及以上)计算;在有刘海与无刘海、多种分辨率下建立测试清单;使用非激活 panel 以避免抢占焦点。 -
后台常驻的性能与能耗。多文件监听加频繁解析可能占用较多 CPU。 缓解方式:
JSONLTailer只读增量并记录字节偏移,FSEvents 去抖,计时不进入状态而由 UI 自行渲染,空闲时降低频率。 -
目录授权的用户授予与流失。用户不授权则该 Agent 不可见。 缓解方式:在向导内引导,运行期检测并优雅降级;授权状态实时反映到 UI。
-
隐私合规红线。不上传任何 prompt、命令、路径或日志。 缓解方式:架构上不引入网络层;提供隐私清单
PrivacyInfo.xcprivacy与本地化的隐私说明;在代码审查中设置检查项。