Skip to content

Latest commit

 

History

History
524 lines (416 loc) · 39.3 KB

TECHNICAL_DESIGN.md

File metadata and controls

524 lines (416 loc) · 39.3 KB

Opsnook 技术设计文档 (Technical Design)

版本 v0.2 · 初版 2026-06-14,开源化修订 2026-08-20 · 配套 docs/PRD.md 与 design/mockups.html 本文是工程层的最高依据。产品方向以 PRD 为准,视觉以 mockups 为准,架构与实现以本文为准。 现状核对:Swift 6 与 XcodeGen 工程已建立并按本文分层运行。本文同时包含现行架构与后续规划,当前已实现的能力见 docs/FEATURES.md。


1. 推荐项目目录结构

核心思路是把核心逻辑层,即模型、状态机、适配器与集成层,抽成一个本地 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,这是一个明确的取舍点。


2. 核心数据模型

放在 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 事件。


3. Agent 适配器协议设计

放在 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
}

三层降级在协议层的体现:

  1. 原生集成(L3):适配器内部使用官方扩展点,例如 Claude hooks,approvalRequested 走 IPC 阻塞通道。
  2. 日志监听(L2):只有 sessionUpserted,没有 approval,detectionMethod 取 .logWatch。
  3. 通用包装器或进程扫描(L1):只产出 running 与 idle,无元数据,emission 退化为最小集合。

运行期若文件访问丢失或解析失败,适配器 emit .healthChanged(.degraded(to: .l1, ...)),UI 据此显示降级态。适配器不得崩溃:任何解析错误都应降级处理,不向 Store 抛出。

注册与发现:AdapterRegistry 持有所有已注册的适配器类型,启动时并发执行 detect(),把已安装的纳入运行,把已安装但未接入的交给接入向导,把未检测到的在设置页置灰。新增 Agent 需要做的是:实现协议、在 registry 中注册、添加 Assets、添加 String Catalog 文案。

3.x 适配范围与已知边界:Antigravity 暂不适配(2026-06-17 调研结论)

该结论已随 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 存储仍不适配。

3.y ZCode 桌面端 L2 已适配(2026-06-17)

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 态,后续可缩短轮询间隔或增加内容变更事件。

3.z 工作区 Git 状态服务(F14,2026-06-19)

工作区页是会话相关仓库的本地 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 禁用该操作并给出本地错误提示;已跟踪文件的回滚必须二次确认。

3.w 会话时间轴查看器(F18,2026-07-02)

把单个会话的全过程可视化为泳道时间轴,区别于第 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 只对子视图生效。隐私边界与工作区页一致,仅在本地解析,不产生新数据,也不上传。

4. 状态机设计

放在 OpsnookCore/State。核心是一个纯函数 reducer,签名为 reduce(session, emission, now) -> (AgentSession, [AgentEvent]),再加上由确定性 Clock 驱动的定时衰减,负责 done 到 idle 的跃迁与等待计时。由于是纯函数,该部分可完全单测。

4.1 合法状态跃迁表

当前 触发 目标 派生事件
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,便于调。

4.2 引擎组成

适配器(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 只存时间戳。


5. 沙盒能力约束与降级

这是本项目最大的结构性约束。概括而言,沙盒会显著削弱读取其他工具私有目录、控制其他窗口、改写其他工具配置这三类核心能力。本项目只有 App Store 沙盒这一个渠道,原 Direct 非沙盒渠道已移除,因此每类核心能力都必须有可接受的沙盒降级路径,并统一经运行期 Capabilities(预设 .appStore)表达,业务层不散落 #if。

5.1 能力矩阵

能力 非沙盒下的天然形态(参考) 沙盒下的实现 降级方案
读 ~/.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 两者一致

5.2 能力开关的代码形态

只有一个渠道,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 用于非无障碍用途,窗口回跳属于此列。相关代码已物理删除,不保留开关。

5.3 关键架构取舍:推与拉

在 App Store 沙盒中主动扫描 ~/.claude 这类拉取方式,既需要书签,也在审核直觉上存在争议。另一种方式是由 Opsnook 安装一个 hook 脚本,让运行在沙盒外的 Agent 在事件发生时把状态 JSON 写进 Opsnook 可读的容器位置,Opsnook 只读自己的容器,不需要额外权限。当前实现采用两者结合:会话状态经用户授权的目录读取,Claude 的审批请求经 hook 写入 App 容器后由 Opsnook 读取并回传决策。

6. M1–M4 开发任务拆分

每个里程碑给出交付物与验收标准,颗粒度到模块或文件级,以确保每一步都是最小可验证增量。

M1 · 重构基础架构(状态引擎 + 适配器框架 + Codex 首适配器 + 基础 UI 壳)

  • T1 工程脚手架:XcodeGen project.yml、App Store 渠道的 xcconfig 与 entitlements、OpsnookKit 本地包、CI(单元测试加渠道编译)。
  • T2 OpsnookCore 模型(第 2 节全部结构)+ 单测。
  • T3 状态机:SessionReducer(跃迁表)+ SessionStore actor + 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 展开后可看到分组列表。目标是端到端打通一个适配器。

M2 · 多 Agent 与核心 UI(F1–F5)

  • 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。

M3 · Beta 与可发布(F6–F9)

  • 通知策略 + 专注模式(F6)。
  • 自动发现 + 接入向导 + 空态引导(F7,设计稿⑥)。
  • 中英双语 String Catalog 全覆盖(F9)+ 英文截断验证。
  • 设置页(设计稿⑤)、权限引导。
  • App Store 提审准备:沙盒、隐私清单、审核话术。

原计划中的 14 天试用与单 Agent Free 授权逻辑(StoreKit / license,对应设计稿⑦)已随开源化取消, 详见 docs/PRD.md 第 10 节。本仓库不含 Licensing 目录,也不要重新引入。

M4 · v1.1(F10–F14)

  • 用量页(设计稿③):配额窗口、成本、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)。

7. 主要技术风险清单

按威胁程度与不确定性的组合排序,每条给出缓解或替代方案。

  1. App Store 审核可能否决核心能力,包括读取其他工具目录、用辅助功能控制窗口、改写其他工具配置。这是最高风险项。 缓解方式:回跳降级到 App 级激活,hooks 改为由用户手动粘贴的兜底路径,目录读取经 security-scoped bookmark 与用户显式授权。当前状态:文件访问、回跳降级与 Privacy Manifest 已落地并经真机验证。首次提交曾因 2.4.5 条款被拒,相关辅助功能代码已物理删除。

  2. 桌面客户端事件接入是 PRD 的假设,公开 API 可能并不存在。Claude Code 与 Codex 的桌面客户端都没有公开的事件接口。 缓解方式:以 CLI 的 JSONL 与 hooks 作为可靠主路径;桌面客户端集成按尽力而为处理,不作为 L3 的前提。

  3. L3 审批回传的可行性,尤其是 Codex。Claude Code 有 hook 的 stdin 决策协议,可由阻塞式 hook 加本地 IPC 实现面板内批准;而 Codex CLI 的审批是 TUI 内交互,缺少干净的程序回传通道。 处理结果:Claude Code 走 hook 加 IPC 路径并已实现;Codex 没有回传通道,已诚实降级为 L2 加一键回跳。

  4. JSONL 格式跨版本漂移。各 Agent 的日志结构随版本变化,严格解析容易失效。 缓解方式:版本化 parser 加多版本基准样本,并做容错降级,即解析失败时退到 L1 而不崩溃;同时把解析层与语义层分离。

  5. 刘海窗口与菜单栏几何,涉及收起态严格不越界、展开时越界、外接屏吸附、刘海机型的 safe area。这类问题容易出现像素级 bug 且机型差异较大。 缓解方式:ScreenGeometry 统一使用 safeAreaInsets 与 auxiliaryTop{Left,Right}Area(macOS 12 及以上)计算;在有刘海与无刘海、多种分辨率下建立测试清单;使用非激活 panel 以避免抢占焦点。

  6. 后台常驻的性能与能耗。多文件监听加频繁解析可能占用较多 CPU。 缓解方式:JSONLTailer 只读增量并记录字节偏移,FSEvents 去抖,计时不进入状态而由 UI 自行渲染,空闲时降低频率。

  7. 目录授权的用户授予与流失。用户不授权则该 Agent 不可见。 缓解方式:在向导内引导,运行期检测并优雅降级;授权状态实时反映到 UI。

  8. 隐私合规红线。不上传任何 prompt、命令、路径或日志。 缓解方式:架构上不引入网络层;提供隐私清单 PrivacyInfo.xcprivacy 与本地化的隐私说明;在代码审查中设置检查项。