From baa84cef601ae950cd59bf347b2be2442caf38c5 Mon Sep 17 00:00:00 2001 From: SculptTechProject <150788324+SculptTechProject@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:22:52 +0200 Subject: [PATCH] Add local agent monitoring and handle ended CLI sessions --- .github/workflows/ci.yml | 2 + Info.plist | 4 +- Integrations/opencode/broschy.js | 162 ++++++ Package.swift | 5 +- README.md | 28 +- Sources/AgentBridge/AgentEvent.swift | 239 +++++++++ .../AgentBridge/AgentProcessIdentity.swift | 98 ++++ Sources/AgentBridge/AgentSession.swift | 116 ++++ Sources/AgentBridge/AgentSessionStore.swift | 149 ++++++ Sources/Broschy/AgentMonitor.swift | 150 ++++++ Sources/Broschy/AgentsView.swift | 293 ++++++++++ Sources/Broschy/App.swift | 47 +- Sources/Broschy/Views.swift | 30 +- Sources/BroschyCLI/AgentCommand.swift | 61 +++ Sources/BroschyCLI/main.swift | 5 + Tests/AgentBridgeTests.swift | 323 +++++++++++ Tests/test_agent_integrations.py | 460 ++++++++++++++++ build.sh | 4 + docs/AGENTS.md | 87 +++ docs/DEVELOPMENT.md | 19 +- docs/assets/agents-dark.png | Bin 0 -> 29478 bytes docs/assets/agents-light.png | Bin 0 -> 28735 bytes scripts/agent-integrations.py | 500 ++++++++++++++++++ test.sh | 6 +- 24 files changed, 2745 insertions(+), 43 deletions(-) create mode 100644 Integrations/opencode/broschy.js create mode 100644 Sources/AgentBridge/AgentEvent.swift create mode 100644 Sources/AgentBridge/AgentProcessIdentity.swift create mode 100644 Sources/AgentBridge/AgentSession.swift create mode 100644 Sources/AgentBridge/AgentSessionStore.swift create mode 100644 Sources/Broschy/AgentMonitor.swift create mode 100644 Sources/Broschy/AgentsView.swift create mode 100644 Sources/BroschyCLI/AgentCommand.swift create mode 100644 Tests/AgentBridgeTests.swift create mode 100644 Tests/test_agent_integrations.py create mode 100644 docs/AGENTS.md create mode 100644 docs/assets/agents-dark.png create mode 100644 docs/assets/agents-light.png create mode 100644 scripts/agent-integrations.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70638ab..5ce39cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,8 @@ jobs: run: | xcodebuild -version swift --version + node --version + python3 --version - name: Run isolated checks run: bash test.sh - name: Build the app diff --git a/Info.plist b/Info.plist index ac541eb..ea1b6bd 100644 --- a/Info.plist +++ b/Info.plist @@ -10,8 +10,8 @@ CFBundleDevelopmentRegionen CFBundleLocalizationsen CFBundlePackageTypeAPPL - CFBundleShortVersionString0.6.0 - CFBundleVersion10 + CFBundleShortVersionString0.7.1 + CFBundleVersion12 LSMinimumSystemVersion14.0 LSUIElement NSHighResolutionCapable diff --git a/Integrations/opencode/broschy.js b/Integrations/opencode/broschy.js new file mode 100644 index 0000000..5b6fc87 --- /dev/null +++ b/Integrations/opencode/broschy.js @@ -0,0 +1,162 @@ +// Broschy's local OpenCode event adapter. The installer fills in the CLI path. +// Only allowlisted lifecycle metadata leaves this plugin; never conversation text. +import { spawn } from "node:child_process"; + +const CLI_PATH = "__BROSCHY_CLI_PATH__"; +const MAX_PENDING = 64; +const MAX_SESSIONS = 256; +const MAX_BYTES = 8192; +const TOOL_NAMES = new Set([ + "bash", "read", "write", "edit", "glob", "grep", "webfetch", "websearch", + "task", "question", "todowrite", "todoread", "patch", "multiedit", "lsp", +]); + +const safeID = (value) => typeof value === "string" && value.length <= 256 + && /^[A-Za-z0-9._:-]+$/.test(value) ? value : undefined; +const safeDirectory = (value) => typeof value === "string" && value.startsWith("/") + && Buffer.byteLength(value, "utf8") <= 4096 && !/[\x00-\x1f\x7f-\x9f]/.test(value) + ? value : undefined; + +export const BroschyPlugin = async (context) => { + const sessions = new Map(); + const pending = []; + let running = false; + const fallbackDirectory = safeDirectory(context?.directory); + + const drain = () => { + if (running || pending.length === 0 || !CLI_PATH.startsWith("/")) return; + running = true; + const payload = pending.shift(); + let child; + let timer; + let finished = false; + const finish = () => { + if (finished) return; + finished = true; + clearTimeout(timer); + running = false; + drain(); + }; + try { + child = spawn(CLI_PATH, ["agent", "hook", "--provider", "opencode"], { + stdio: ["pipe", "ignore", "ignore"], + windowsHide: true, + }); + child.once("error", finish); + child.once("close", finish); + child.stdin.on("error", () => {}); + child.stdin.end(payload); + timer = setTimeout(() => { + try { child.kill("SIGKILL"); } catch {} + finish(); + }, 2000); + timer.unref?.(); + child.unref?.(); + } catch { + try { child?.kill("SIGKILL"); } catch {} + finish(); + } + }; + + const enqueue = (payload) => { + const serialized = JSON.stringify(payload) + "\n"; + if (Buffer.byteLength(serialized, "utf8") > MAX_BYTES) return; + if (pending[pending.length - 1] === serialized) return; + if (pending.length >= MAX_PENDING) { + // Prefer dropping an old heartbeat over a permission/question transition. + const heartbeat = pending.findIndex((item) => item.includes('"hook_event_name":"session.status"') + || item.includes('"hook_event_name":"session.updated"')); + pending.splice(heartbeat >= 0 ? heartbeat : 0, 1); + } + pending.push(serialized); + drain(); + }; + + return { + event: async ({ event } = {}) => { + // Event callbacks never wait for Broschy or alter the agent's result. + try { + if (!event || typeof event.type !== "string") return; + const properties = event.properties; + if (!properties || typeof properties !== "object" || Array.isArray(properties)) return; + let sessionID; + let requestID; + let status; + let toolName; + let role; + let metadata; + switch (event.type) { + case "session.created": + case "session.updated": { + const info = properties.info; + if (!info || typeof info !== "object") return; + sessionID = safeID(info.id); + if (!sessionID) return; + const previous = sessions.get(sessionID); + metadata = { + cwd: safeDirectory(info.directory) ?? previous?.cwd ?? fallbackDirectory, + parentSessionID: safeID(info.parentID) ?? previous?.parentSessionID, + }; + sessions.delete(sessionID); + sessions.set(sessionID, metadata); + if (sessions.size > MAX_SESSIONS) sessions.delete(sessions.keys().next().value); + break; + } + case "session.status": + sessionID = safeID(properties.sessionID); + status = properties.status?.type; + if (!["busy", "idle", "retry"].includes(status)) return; + break; + case "session.idle": + case "session.deleted": + case "session.error": + sessionID = safeID(properties.sessionID ?? properties.info?.id); + break; + case "permission.asked": + sessionID = safeID(properties.sessionID); + requestID = safeID(properties.id); + toolName = TOOL_NAMES.has(properties.permission) ? properties.permission : undefined; + if (!requestID) return; + break; + case "question.asked": + sessionID = safeID(properties.sessionID); + requestID = safeID(properties.id); + if (!requestID) return; + break; + case "permission.replied": + case "question.replied": + case "question.rejected": + sessionID = safeID(properties.sessionID); + requestID = safeID(properties.requestID); + if (!requestID) return; + break; + case "message.updated": + if (properties.info?.role !== "user") return; + sessionID = safeID(properties.info.sessionID); + role = "user"; + break; + default: + return; + } + // Global errors have no trustworthy session ID and are not attributed. + if (!sessionID) return; + metadata = metadata ?? sessions.get(sessionID); + const cwd = metadata?.cwd ?? fallbackDirectory; + if (!cwd) return; + enqueue({ + hook_event_name: event.type, + session_id: sessionID, + cwd, + request_id: requestID, + status, + tool_name: toolName, + parent_session_id: metadata?.parentSessionID, + role, + }); + if (event.type === "session.deleted") sessions.delete(sessionID); + } catch { + // Monitoring is best effort. Never block a coding session on an adapter error. + } + }, + }; +}; diff --git a/Package.swift b/Package.swift index 500ab78..02f095f 100644 --- a/Package.swift +++ b/Package.swift @@ -9,8 +9,9 @@ let package = Package( .executable(name: "broschy-cli", targets: ["BroschyCLI"]) ], targets: [ - .executableTarget(name: "Broschy"), - .executableTarget(name: "BroschyCLI") + .target(name: "AgentBridge"), + .executableTarget(name: "Broschy", dependencies: ["AgentBridge"]), + .executableTarget(name: "BroschyCLI", dependencies: ["AgentBridge"]) ], swiftLanguageModes: [.v5] ) diff --git a/README.md b/README.md index e00c923..8136780 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Broschy

A little more life around your notch.
-Focus, music, command results, and quick notes — one glance away.

+Agents, focus, music, command results, and quick notes — one glance away.

Build and test @@ -14,7 +14,7 @@ Focus, music, command results, and quick notes — one glance away.

MIT license

-

Get started · Spotify · Terminal signals · Contribute

+

Get started · Agents · Spotify · Terminal signals · Contribute

Broschy is a small, native macOS companion that lives around your MacBook's notch. Hover to open it, take care of something, and get back to your work. A translucent compact strip keeps the essentials visible without opening a full window. @@ -28,10 +28,11 @@ Broschy is a small, native macOS companion that lives around your MacBook's notc

Actual app views with sample data. Glass adapts to your appearance and the content behind it.

-## Four things, close at hand +## Five things, close at hand | | What it does | | :--- | :--- | +| **Agents** | Monitor local Codex, Claude Code, and OpenCode sessions. See who is working, waiting for you, or ready for review. | | **Focus** | Keep one task in view. Start a 25, 50, or 90-minute session, pause it, and pick up where you left off. The timer accounts for sleep. | | **Music** | See the current Spotify track and artwork. Play, pause, skip, seek, and adjust Spotify's volume. The compact view keeps the title, artist, elapsed time, and animated playback bars nearby. | | **Signals** | Run a build or test through the bundled CLI and see when it finishes. Success, failure, and cancellation appear in the panel. | @@ -42,12 +43,12 @@ Broschy is a small, native macOS companion that lives around your MacBook's notc - **Liquid Glass, expanded and compact.** Native glass on macOS 26, with a standard macOS material on earlier versions. - **Light and Dark, automatically.** Text, accents, and surfaces follow the system appearance, including Auto. - **Motion with a purpose.** Smooth open/close transitions and small playback indicators; Reduce Motion and Reduce Transparency are respected. -- **One place to look.** Active focus sessions and recent command results take priority over music in the compact view. +- **One place to look.** Agent attention comes first; active focus sessions, recent command results, and working agents also take priority over music in the compact view. - **Native and small.** SwiftUI, AppKit, a bundled CLI, and no third-party Swift dependencies. English throughout. ## Get started -**Current version: 0.6.0.** Broschy is an early, source-first release. Build it locally with **Xcode 26 or later** and its macOS SDK. The app's deployment target is **macOS 14**; hands-on validation has been on macOS 26 and Apple Silicon. The build produces an app for your Mac's architecture. +**Current version: 0.7.1.** Broschy is an early, source-first release. Build it locally with **Xcode 26 or later** and its macOS SDK. The app's deployment target is **macOS 14**; hands-on validation has been on macOS 26 and Apple Silicon. The build produces an app for your Mac's architecture. ```sh git clone https://github.com/SculptTechProject/Broschy.git @@ -70,6 +71,16 @@ The build script assembles `build/Broschy.app` and signs it locally with an ad h Hiding the panel keeps Broschy running. To reopen it after quitting, launch `Broschy.app`. Broschy has no Dock icon and does not automatically start at login. On a screen without a notch, it appears at the center of the top edge. +## Agents + +

Agents with sample Claude Code, Codex, and OpenCode sessions, including a Needs you queue
Sample sessions in Dark appearance.

+ +Open **Agents → Connections** to connect Codex, Claude Code, or OpenCode. Restart the connected tool; Codex may require you to review the new hooks with `/hooks`. Sessions appear when they send an event. + +The **Needs you** queue gathers questions, permission requests, responses ready for review, and errors. The compact notch signals activity without opening a full window. Session actions let you copy a terminal resume command, open the project folder, and mark a response reviewed. + +This is a local monitor for supported CLI hooks and plugins: it does not open transcripts or store prompts, tool arguments, or output. It does not send prompts, approve tools, or stop agents. The Codex desktop app is not currently a verified integration. Activity is checked against the originating process when available; stale activity becomes “Status unknown” instead of staying “Working”. “Ready to review” means a response stopped, not that the task necessarily succeeded. Setup needs Python 3. See the [integration guide](docs/AGENTS.md) for compatibility, configuration, removal, and limitations. + ## Spotify 1. Open the **Spotify desktop app** on your Mac. @@ -103,14 +114,15 @@ Broschy sees commands you run through its CLI and results you explicitly send. I ## Your data stays on your Mac -The task, timer, note, and command history are stored locally. Broschy has no account, analytics, or cloud sync. Command records contain a label, working directory, status, timestamps, and an exit code when available; command arguments and output are not saved. Album artwork is fetched over HTTPS from URLs returned by Spotify. No listening history or Spotify tokens are stored. +The task, timer, note, and command history are stored locally. Broschy has no account, analytics, or cloud sync. Agent snapshots contain session and project identifiers, status, generic activity labels, and timestamps; prompts and tool contents are not saved. Integration setup retains local configuration backups. Command records contain a label, working directory, status, timestamps, and an exit code when available; command arguments and output are not saved. Album artwork is fetched over HTTPS from URLs returned by Spotify. No listening history or Spotify tokens are stored. For compatibility with earlier installations, the storage folder remains: ```text ~/Library/Application Support/NotchFlow/ ├── state.json -└── jobs/ +├── jobs/ +└── agents/ ``` Use **Open Broschy Data** from the menu bar to find it. The legacy bundle identifier is also retained so existing preferences and macOS permissions keep the same application identity. See [development notes](docs/DEVELOPMENT.md) for details. @@ -118,7 +130,7 @@ Use **Open Broschy Data** from the menu bar to find it. The legacy bundle identi ## Development ```sh -bash test.sh # Isolated timer, storage, CLI, and mocked Spotify checks +bash test.sh # Isolated core, agent bridge, installer, and mocked Spotify checks bash build.sh # Release app bundle, icon, and local signature ``` diff --git a/Sources/AgentBridge/AgentEvent.swift b/Sources/AgentBridge/AgentEvent.swift new file mode 100644 index 0000000..ffe7e26 --- /dev/null +++ b/Sources/AgentBridge/AgentEvent.swift @@ -0,0 +1,239 @@ +import Foundation + +enum AgentEventKind: Equatable { + case started, metadata, newTurn, working, attention, resolved, ready, error, idle, closed +} + +struct AgentEvent { + static let maximumInputBytes = 256 * 1_024 + let provider: AgentProvider + let sessionID: String + let cwd: String? + let parentSessionID: String? + let name: String + let kind: AgentEventKind + let detail: String + let requestID: String? + // A successful/failed tool callback proves the corresponding tool request + // has resumed; unrelated busy events do not prove permission was granted. + let resolvesToolRequest: Bool + + static let eventNames: Set = [ + "SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse", "PostToolUse", + "PostToolUseFailure", "PermissionRequest", "Notification", "Stop", "StopFailure", + "Interrupt", "Elicitation", "ElicitationResult", + "session.created", "session.updated", "session.deleted", "session.status", "session.idle", "session.error", + "permission.asked", "permission.replied", "permission.updated", "question.asked", + "question.replied", "question.rejected", "message.updated", "tool.execute.before", "tool.execute.after" + ] + + private static let toolNames: Set = [ + "Bash", "Read", "Write", "Edit", "MultiEdit", "Glob", "Grep", "WebFetch", "WebSearch", + "Task", "Agent", "TodoWrite", "NotebookEdit", "AskUserQuestion", "ExitPlanMode", + "exec_command", "write_stdin", "apply_patch", "request_user_input", "spawn_agent", + "wait_agent", "send_message", "shell", "shell_command", "read_file", "list_dir", + "bash", "read", "write", "edit", "glob", "grep", "webfetch", "websearch", "task", + "question", "todowrite", "todoread", "patch", "multiedit", "lsp" + ] + + static func validID(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 256 + && value.unicodeScalars.allSatisfy { CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._:-").contains($0) } + } + + static func validCWD(_ value: String) -> Bool { + value.hasPrefix("/") && value.utf8.count <= 4_096 + && !value.unicodeScalars.contains { CharacterSet.controlCharacters.contains($0) } + } + + static func safeDetail(_ value: String) -> Bool { + ["Session started", "Working", "Waiting for permission", "Waiting for your answer", + "Needs your attention", "Response received", "Ready to review", "Agent reported an error", + "Tool reported an error", "Interrupted", "Idle", "Session ended", "Using a tool"].contains(value) + || (value.hasPrefix("Using ") && toolNames.contains(String(value.dropFirst(6)))) + } + + static func parse(provider: AgentProvider, data: Data) throws -> AgentEvent? { + guard data.count <= maximumInputBytes, + let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let rawID = payload["session_id"] as? String, validID(rawID), + let name = payload["hook_event_name"] as? String, eventNames.contains(name) else { + throw AgentBridgeError.invalidInput + } + let cwd = payload["cwd"] as? String + if let cwd, !validCWD(cwd) { throw AgentBridgeError.invalidInput } + let parent = payload["parent_session_id"] as? String + if let parent, !validID(parent) { throw AgentBridgeError.invalidInput } + let rawTool = payload["tool_name"] as? String + let tool = rawTool.flatMap { value -> String? in + let candidate = value.components(separatedBy: ".").last ?? value + return toolNames.contains(candidate) ? candidate : nil + } + let toolDetail = tool.map { "Using " + $0 } ?? "Using a tool" + let rawRequest = (payload["request_id"] ?? payload["tool_use_id"] ?? payload["call_id"] ?? payload["elicitation_id"]) as? String + if let rawRequest, !validID(rawRequest) { throw AgentBridgeError.invalidInput } + var request = rawRequest + var kind: AgentEventKind + var detail: String + var resolvesToolRequest = false + + if provider == .opencode { + switch name { + case "session.created": kind = .started; detail = "Session started" + case "session.updated": kind = .metadata; detail = "Session started" + case "session.deleted": kind = .closed; detail = "Session ended" + case "session.status": + switch payload["status"] as? String { + case "busy", "retry": kind = .working; detail = "Working" + case "idle": kind = .ready; detail = "Ready to review" + default: return nil + } + case "session.idle": kind = .ready; detail = "Ready to review" + case "session.error": kind = .error; detail = "Agent reported an error" + case "permission.asked", "permission.updated": kind = .attention; detail = "Waiting for permission" + case "question.asked": kind = .attention; detail = "Waiting for your answer" + case "permission.replied", "question.replied", "question.rejected": + guard request != nil else { return nil } + kind = .resolved; detail = "Response received" + case "message.updated": + guard payload["role"] as? String == "user" else { return nil } + kind = .newTurn; detail = "Working" + case "tool.execute.before": + if tool == "question" { kind = .attention; detail = "Waiting for your answer" } + else { kind = .working; detail = toolDetail } + case "tool.execute.after": kind = .working; detail = toolDetail; resolvesToolRequest = true + default: return nil + } + } else { + switch name { + case "SessionStart": kind = .started; detail = "Session started" + case "SessionEnd": kind = .closed; detail = "Session ended" + case "UserPromptSubmit": kind = .newTurn; detail = "Working" + case "PreToolUse": + if ["request_user_input", "AskUserQuestion", "question"].contains(tool ?? "") { + kind = .attention; detail = "Waiting for your answer" + } else { kind = .working; detail = toolDetail } + case "PostToolUse": kind = .working; detail = toolDetail; resolvesToolRequest = true + case "PostToolUseFailure": kind = .error; detail = "Tool reported an error"; resolvesToolRequest = true + case "PermissionRequest": kind = .attention; detail = "Waiting for permission" + case "Elicitation": kind = .attention; detail = "Waiting for your answer" + case "ElicitationResult": kind = .resolved; detail = "Response received" + case "Notification": + switch payload["notification_type"] as? String { + case "permission_prompt": kind = .attention; detail = "Waiting for permission" + case "elicitation_dialog": kind = .attention; detail = "Waiting for your answer" + default: return nil + } + case "Stop": kind = .ready; detail = "Ready to review" + case "StopFailure": kind = .error; detail = "Agent reported an error" + case "Interrupt": kind = .idle; detail = "Interrupted" + default: return nil + } + } + if kind == .attention && request == nil { + // Providers do not always attach an ID to UI notifications. Keep + // that wait distinct from explicit permission IDs. + request = detail == "Waiting for permission" ? "unidentified-permission" : "unidentified-question" + } + return AgentEvent(provider: provider, sessionID: rawID, cwd: cwd, + parentSessionID: parent, name: name, kind: kind, + detail: detail, requestID: request, resolvesToolRequest: resolvesToolRequest) + } + + func applying(to previous: AgentSession?, now: Date) throws -> AgentSession { + guard previous != nil || cwd != nil else { throw AgentBridgeError.invalidInput } + var session = previous ?? AgentSession(provider: provider, sessionID: sessionID, cwd: cwd!, updatedAt: now) + if now < session.updatedAt { return session } + if let cwd { session.cwd = cwd; session.title = AgentSession.projectTitle(cwd) } + if let parentSessionID { session.parentSessionID = parentSessionID } + // Renames/directory metadata are not evidence that the agent is still + // executing the last user turn. Preserve the activity timestamp/event. + if kind == .metadata { return session } + + // A stray idle/Stop delivery cannot resurrect a session that ended. + if session.status == .closed && kind != .started && kind != .newTurn { return session } + let previousStatus = session.status + session.updatedAt = now + session.lastEvent = name + + if resolvesToolRequest { + if let requestID { resolve(requestID, in: &session) } + // ID-less notifications are resolved only by an explicit tool + // completion or a new user turn, never by generic busy/idle. + resolve("unidentified-permission", in: &session) + resolve("unidentified-question", in: &session) + } + switch kind { + case .started: + if previous == nil || session.status == .closed { + session.status = .idle; session.detail = detail + session.pendingRequestIDs = []; session.resolvedRequestIDs = [] + session.lastFailureAt = nil + session.acknowledgedAt = nil + } + case .metadata: break + case .newTurn: + session.status = .working; session.detail = detail; session.acknowledgedAt = nil + session.lastFailureAt = nil + resolve("unidentified-permission", in: &session) + resolve("unidentified-question", in: &session) + case .working: + if session.status != .error { session.status = .working; session.detail = detail; session.acknowledgedAt = nil } + case .attention: + if let requestID, !session.resolvedRequestIDs.contains(requestID), + !session.pendingRequestIDs.contains(requestID) { + if session.pendingRequestIDs.count < 63 { + session.pendingRequestIDs.append(requestID) + } else if !session.pendingRequestIDs.contains("untracked-request-overflow") { + // If an implausible number of simultaneous requests exceeds + // the bound, remain waiting instead of later claiming every + // request was resolved. This marker clears at session end. + session.pendingRequestIDs.append("untracked-request-overflow") + } + } + if !session.pendingRequestIDs.isEmpty { session.status = .needsAttention; session.detail = detail; session.acknowledgedAt = nil } + case .resolved: + let wasPending = requestID.map { session.pendingRequestIDs.contains($0) } + ?? session.pendingRequestIDs.contains("unidentified-question") + if let requestID { resolve(requestID, in: &session) } + else { + resolve("unidentified-question", in: &session) + } + if wasPending && session.status != .error { session.status = .working; session.detail = detail; session.acknowledgedAt = nil } + case .ready: + if session.status != .error { + session.status = .ready; session.detail = detail + if previousStatus != .ready { session.acknowledgedAt = nil } + } + case .error: + session.status = .error; session.detail = detail; session.acknowledgedAt = nil + session.lastFailureAt = now + case .idle: + if session.status != .error { session.status = .idle; session.detail = detail; session.acknowledgedAt = nil } + case .closed: + session.status = .closed; session.detail = detail; session.pendingRequestIDs = [] + } + if session.lastFailureAt != nil && session.status != .closed { + session.status = .error; session.detail = "Agent reported an error" + } + if !session.pendingRequestIDs.isEmpty && session.status != .closed { + session.status = .needsAttention + if !["Waiting for permission", "Waiting for your answer"].contains(session.detail) { + session.detail = "Needs your attention" + } + } + return session + } + + private func resolve(_ request: String, in session: inout AgentSession) { + session.pendingRequestIDs.removeAll { $0 == request } + // ID-less notifications must be able to recur during the next tool. + guard !request.hasPrefix("unidentified-") else { return } + if !session.resolvedRequestIDs.contains(request) { session.resolvedRequestIDs.append(request) } + session.resolvedRequestIDs = Array(session.resolvedRequestIDs.suffix(128)) + } +} + +public enum AgentBridgeError: Error { + case invalidInput, unsafeStorage, busy, ioFailure +} diff --git a/Sources/AgentBridge/AgentProcessIdentity.swift b/Sources/AgentBridge/AgentProcessIdentity.swift new file mode 100644 index 0000000..594f3ff --- /dev/null +++ b/Sources/AgentBridge/AgentProcessIdentity.swift @@ -0,0 +1,98 @@ +import Darwin +import Foundation + +public enum AgentProcessLiveness: Sendable { + case alive, exited, unknown +} + +/// An exact local process identity. PID alone is insufficient because macOS +/// reuses it after exit. No executable path or command-line arguments are saved. +public struct AgentProcessIdentity: Codable, Equatable, Sendable { + public let pid: Int32 + public let startedAtSeconds: UInt64 + public let startedAtMicroseconds: UInt64 + public let executableName: String + + public init(pid: Int32, startedAtSeconds: UInt64, startedAtMicroseconds: UInt64, + executableName: String) { + self.pid = pid + self.startedAtSeconds = startedAtSeconds + self.startedAtMicroseconds = startedAtMicroseconds + self.executableName = executableName + } + + /// Inspect only the hook's ancestry, never all processes with a matching + /// name. A different Codex window must not keep an old session "Working". + public static func captureOwner(provider: AgentProvider, startingAt pid: Int32 = getppid()) -> Self? { + var currentPID = pid + var visited = Set() + for _ in 0..<8 { + guard currentPID > 1, visited.insert(currentPID).inserted, + let info = processInfo(currentPID), info.pbi_uid == getuid() else { return nil } + if let path = executablePath(currentPID) { + let name = URL(fileURLWithPath: path).lastPathComponent + // The desktop app-server is long-lived and shared across tasks. + // It cannot establish ownership of a terminal CLI session. + if !path.contains(".app/Contents/"), names(for: provider).contains(name), + let confirmed = processInfo(currentPID), + confirmed.pbi_start_tvsec == info.pbi_start_tvsec, + confirmed.pbi_start_tvusec == info.pbi_start_tvusec, + confirmed.pbi_status != UInt32(SZOMB) { + let identity = Self(pid: currentPID, startedAtSeconds: info.pbi_start_tvsec, + startedAtMicroseconds: info.pbi_start_tvusec, executableName: name) + return identity.isValid(for: provider) ? identity : nil + } + } + guard info.pbi_ppid <= UInt32(Int32.max) else { return nil } + currentPID = Int32(info.pbi_ppid) + } + return nil + } + + public func liveness() -> AgentProcessLiveness { + guard pid > 1, startedAtSeconds > 0, startedAtMicroseconds < 1_000_000 else { return .unknown } + guard let info = Self.processInfo(pid) else { + // EPERM/limited process visibility is uncertainty, not proof of exit. + return kill(pid, 0) == -1 && errno == ESRCH ? .exited : .unknown + } + guard info.pbi_start_tvsec == startedAtSeconds, + info.pbi_start_tvusec == startedAtMicroseconds else { return .exited } + if info.pbi_status == UInt32(SZOMB) { return .exited } + guard info.pbi_uid == getuid(), let path = Self.executablePath(pid) else { return .unknown } + guard URL(fileURLWithPath: path).lastPathComponent == executableName else { return .exited } + guard let confirmed = Self.processInfo(pid) else { + return kill(pid, 0) == -1 && errno == ESRCH ? .exited : .unknown + } + guard confirmed.pbi_start_tvsec == startedAtSeconds, + confirmed.pbi_start_tvusec == startedAtMicroseconds, + confirmed.pbi_status != UInt32(SZOMB) else { return .exited } + return .alive + } + + func isValid(for provider: AgentProvider) -> Bool { + pid > 1 && startedAtSeconds > 0 && startedAtMicroseconds < 1_000_000 + && startedAtSeconds <= UInt64(Date().timeIntervalSince1970 + 60) + && Self.names(for: provider).contains(executableName) + } + + private static func names(for provider: AgentProvider) -> Set { + switch provider { + case .codex: return ["codex", "codex-aarch64-apple-darwin", "codex-x86_64-apple-darwin"] + case .claude: return ["claude"] + case .opencode: return ["opencode", "opencode.exe", "opencode-darwin-arm64", "opencode-darwin-x64"] + } + } + + private static func processInfo(_ pid: Int32) -> proc_bsdinfo? { + var info = proc_bsdinfo() + let size = Int32(MemoryLayout.size) + return proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, size) == size ? info : nil + } + + private static func executablePath(_ pid: Int32) -> String? { + // PROC_PIDPATHINFO_MAXSIZE is a C expression unavailable to Swift. + var buffer = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN)) + guard proc_pidpath(pid, &buffer, UInt32(buffer.count)) > 0 else { return nil } + return String(cString: buffer) + } +} diff --git a/Sources/AgentBridge/AgentSession.swift b/Sources/AgentBridge/AgentSession.swift new file mode 100644 index 0000000..af9ee5a --- /dev/null +++ b/Sources/AgentBridge/AgentSession.swift @@ -0,0 +1,116 @@ +import CryptoKit +import Foundation + +public enum AgentProvider: String, Codable, CaseIterable, Sendable { + case codex, claude, opencode + + public var displayName: String { + switch self { + case .codex: return "Codex" + case .claude: return "Claude Code" + case .opencode: return "OpenCode" + } + } +} + +public enum AgentStatus: String, Codable, CaseIterable, Sendable { + case idle, working, needsAttention, ready, error, closed, unknown +} + +/// A deliberately small snapshot. It never contains prompts, tool input/output, +/// transcripts, permission descriptions, or generated conversation titles. +public struct AgentSession: Codable, Equatable, Identifiable, Sendable { + public let id: String + public let provider: AgentProvider + public let sessionID: String + public var cwd: String + public var title: String + public var status: AgentStatus + public var detail: String + public var updatedAt: Date + public let startedAt: Date + public var parentSessionID: String? + public var pendingRequestIDs: [String] + public var lastEvent: String + public var acknowledgedAt: Date? + + // Bounded tombstones prevent late duplicate permission requests from + // resurrecting a request whose response has already arrived. + public var resolvedRequestIDs: [String] + public var lastFailureAt: Date? + public var ownerProcess: AgentProcessIdentity? + + public init(provider: AgentProvider, sessionID: String, cwd: String, + status: AgentStatus = .idle, detail: String = "Session started", + updatedAt: Date = Date(), startedAt: Date? = nil, + parentSessionID: String? = nil, pendingRequestIDs: [String] = [], + lastEvent: String = "SessionStart", acknowledgedAt: Date? = nil, + ownerProcess: AgentProcessIdentity? = nil) { + self.id = Self.stableID(provider: provider, sessionID: sessionID) + self.provider = provider + self.sessionID = sessionID + self.cwd = cwd + self.title = Self.projectTitle(cwd) + self.status = status + self.detail = detail + self.updatedAt = updatedAt + self.startedAt = startedAt ?? updatedAt + self.parentSessionID = parentSessionID + self.pendingRequestIDs = pendingRequestIDs + self.lastEvent = lastEvent + self.acknowledgedAt = acknowledgedAt + self.resolvedRequestIDs = [] + self.lastFailureAt = status == .error ? updatedAt : nil + self.ownerProcess = ownerProcess + } + + public static func stableID(provider: AgentProvider, sessionID: String) -> String { + SHA256.hash(data: Data((provider.rawValue + "\u{0}" + sessionID).utf8)) + .map { String(format: "%02x", $0) }.joined() + } + + public func effectiveStatus(at date: Date = Date(), ownerLiveness: AgentProcessLiveness? = nil) -> AgentStatus { + // Finished responses/errors remain available for review after exit. + guard [.working, .needsAttention, .idle, .unknown].contains(status) else { return status } + let liveness = ownerProcess.map { ownerLiveness ?? $0.liveness() } ?? .unknown + if liveness == .exited { return .closed } + let age = date.timeIntervalSince(updatedAt) + if liveness == .unknown && age > 75 { return .unknown } + // An open process is not proof that a particular turn is still working. + if liveness == .alive && status == .working && age > 120 { + return .unknown + } + if liveness == .alive && status == .needsAttention && age > 30 * 60 { + return .unknown + } + return status + } + + public func status(at date: Date) -> AgentStatus { effectiveStatus(at: date) } + + public func needsAttention(at date: Date = Date()) -> Bool { + let current = effectiveStatus(at: date) + return current == .needsAttention || ((current == .ready || current == .error) && acknowledgedAt == nil) + } + + static func projectTitle(_ cwd: String) -> String { + let name = URL(fileURLWithPath: cwd).lastPathComponent + return name.isEmpty || name == "/" ? "Agent session" : String(name.prefix(80)) + } + + var isValid: Bool { + AgentEvent.validID(sessionID) && AgentEvent.validCWD(cwd) + && id == Self.stableID(provider: provider, sessionID: sessionID) + && title == Self.projectTitle(cwd) + && detail.utf8.count <= 160 && AgentEvent.safeDetail(detail) + && pendingRequestIDs.count <= 64 && resolvedRequestIDs.count <= 128 + && pendingRequestIDs.allSatisfy(AgentEvent.validID) + && resolvedRequestIDs.allSatisfy(AgentEvent.validID) + && Set(pendingRequestIDs).count == pendingRequestIDs.count + && Set(pendingRequestIDs).isDisjoint(with: Set(resolvedRequestIDs)) + && (parentSessionID == nil || AgentEvent.validID(parentSessionID!)) + && AgentEvent.eventNames.contains(lastEvent) + && (ownerProcess == nil || ownerProcess!.isValid(for: provider)) + && updatedAt.timeIntervalSince1970.isFinite && startedAt.timeIntervalSince1970.isFinite + } +} diff --git a/Sources/AgentBridge/AgentSessionStore.swift b/Sources/AgentBridge/AgentSessionStore.swift new file mode 100644 index 0000000..ad937e1 --- /dev/null +++ b/Sources/AgentBridge/AgentSessionStore.swift @@ -0,0 +1,149 @@ +import Darwin +import Foundation + +/// Separate per-session snapshots let short-lived hooks update concurrently. +/// Writes use an advisory lock and atomic rename; readers never see partial JSON. +public struct AgentSessionStore: Sendable { + public let rootDirectory: URL + public var sessionsDirectory: URL { rootDirectory.appendingPathComponent("sessions", isDirectory: true) } + private static let maximumSnapshotBytes = 64 * 1_024 + + public init(rootDirectory: URL? = nil) { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + self.rootDirectory = rootDirectory ?? support.appendingPathComponent("NotchFlow/agents", isDirectory: true) + } + + /// Reading does not create directories or modify agent configuration. + public func readSessions(limit: Int = 512) throws -> [AgentSession] { + guard FileManager.default.fileExists(atPath: sessionsDirectory.path) else { return [] } + try checkDirectory(rootDirectory.deletingLastPathComponent()) + try checkDirectory(rootDirectory) + try checkDirectory(sessionsDirectory) + let files = try FileManager.default.contentsOfDirectory(at: sessionsDirectory, includingPropertiesForKeys: [.contentModificationDateKey]) + .filter { $0.pathExtension == "json" && $0.deletingPathExtension().lastPathComponent.count == 64 } + .map { file in (file: file, modified: (try? file.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast) } + .sorted { $0.modified > $1.modified } + .prefix(min(512, max(1, limit))) + return files.compactMap { file in + guard let session = try? read(file.file), session.id + ".json" == file.file.lastPathComponent else { return nil } + return session + }.sorted { $0.updatedAt > $1.updatedAt } + } + + @discardableResult + public func ingest(provider: AgentProvider, data: Data, now: Date = Date(), + ownerProcess: AgentProcessIdentity? = nil) throws -> AgentSession? { + guard let event = try AgentEvent.parse(provider: provider, data: data) else { return nil } + let id = AgentSession.stableID(provider: provider, sessionID: event.sessionID) + return try withSessionLock(id: id) { + let file = sessionsDirectory.appendingPathComponent(id + ".json") + let existing = try readIfPresent(file) + var result = try event.applying(to: existing, now: now) + if event.kind != .metadata && result != existing { + // Identity belongs to this delivery, not a previous terminal + // invocation of the same resumed session. Missing evidence + // falls back to the short unverified freshness window. + result.ownerProcess = ownerProcess + } + guard result.isValid else { throw AgentBridgeError.invalidInput } + if result != existing { try write(result, to: file) } + return result + } + } + + /// Active permission requests always need a response in the agent's own app. + @discardableResult + public func acknowledge(provider: AgentProvider, sessionID: String, expectedUpdatedAt: Date? = nil, + now: Date = Date()) throws -> AgentSession? { + guard AgentEvent.validID(sessionID) else { throw AgentBridgeError.invalidInput } + let id = AgentSession.stableID(provider: provider, sessionID: sessionID) + return try withSessionLock(id: id) { + let file = sessionsDirectory.appendingPathComponent(id + ".json") + guard var session = try readIfPresent(file) else { return nil } + if let expectedUpdatedAt, session.updatedAt != expectedUpdatedAt { return session } + if session.pendingRequestIDs.isEmpty && [.ready, .error].contains(session.status) { + session.acknowledgedAt = now + try write(session, to: file) + } + return session + } + } + + private func prepareDirectory() throws { + let parent = rootDirectory.deletingLastPathComponent() + if FileManager.default.fileExists(atPath: parent.path) { try checkDirectory(parent) } + for directory in [rootDirectory, sessionsDirectory] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, + attributes: [.posixPermissions: NSNumber(value: 0o700)]) + try checkDirectory(directory) + } + } + + private func checkDirectory(_ directory: URL) throws { + var info = stat() + guard lstat(directory.path, &info) == 0, (info.st_mode & S_IFMT) == S_IFDIR, + info.st_uid == getuid() else { throw AgentBridgeError.unsafeStorage } + } + + private func withSessionLock(id: String, _ action: () throws -> T) throws -> T { + try prepareDirectory() + let lockURL = sessionsDirectory.appendingPathComponent(id + ".lock") + let descriptor = open(lockURL.path, O_CREAT | O_RDWR | O_NOFOLLOW | O_CLOEXEC, mode_t(0o600)) + guard descriptor >= 0 else { throw AgentBridgeError.unsafeStorage } + defer { close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == getuid(), info.st_nlink == 1 else { throw AgentBridgeError.unsafeStorage } + let deadline = DispatchTime.now().uptimeNanoseconds + 500_000_000 + while flock(descriptor, LOCK_EX | LOCK_NB) != 0 { + guard errno == EWOULDBLOCK || errno == EAGAIN else { throw AgentBridgeError.ioFailure } + guard DispatchTime.now().uptimeNanoseconds < deadline else { throw AgentBridgeError.busy } + usleep(5_000) + } + defer { flock(descriptor, LOCK_UN) } + return try action() + } + + private func readIfPresent(_ file: URL) throws -> AgentSession? { + var info = stat() + if lstat(file.path, &info) != 0 { + if errno == ENOENT { return nil } + throw AgentBridgeError.ioFailure + } + return try read(file) + } + + private func read(_ file: URL) throws -> AgentSession { + let descriptor = open(file.path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard descriptor >= 0 else { throw AgentBridgeError.unsafeStorage } + defer { close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == getuid(), info.st_nlink == 1, info.st_size > 0, + info.st_size <= Self.maximumSnapshotBytes else { throw AgentBridgeError.unsafeStorage } + var data = Data(count: Int(info.st_size)) + let expected = data.count + let amount = data.withUnsafeMutableBytes { Darwin.read(descriptor, $0.baseAddress, expected) } + guard amount == expected else { throw AgentBridgeError.ioFailure } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + let session = try decoder.decode(AgentSession.self, from: data) + guard session.isValid else { throw AgentBridgeError.invalidInput } + return session + } + + private func write(_ session: AgentSession, to file: URL) throws { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(session) + guard data.count <= Self.maximumSnapshotBytes else { throw AgentBridgeError.invalidInput } + let temporary = sessionsDirectory.appendingPathComponent("." + UUID().uuidString + ".tmp") + let descriptor = open(temporary.path, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, mode_t(0o600)) + guard descriptor >= 0 else { throw AgentBridgeError.ioFailure } + defer { close(descriptor); unlink(temporary.path) } + let written = data.withUnsafeBytes { Darwin.write(descriptor, $0.baseAddress, data.count) } + guard written == data.count, fsync(descriptor) == 0, + rename(temporary.path, file.path) == 0 else { throw AgentBridgeError.ioFailure } + } +} diff --git a/Sources/Broschy/AgentMonitor.swift b/Sources/Broschy/AgentMonitor.swift new file mode 100644 index 0000000..006b7cb --- /dev/null +++ b/Sources/Broschy/AgentMonitor.swift @@ -0,0 +1,150 @@ +import AgentBridge +import AppKit +import Combine +import Foundation + +@MainActor +final class AgentMonitor: ObservableObject { + @Published private(set) var sessions: [AgentSession] = [] + @Published private(set) var storageError: String? + private let storage: AgentSessionStore + private let queue = DispatchQueue(label: "app.broschy.agents", qos: .utility) + private var ticker: Timer? + private var refreshing = false + + init(rootDirectory: URL? = nil, startTicker: Bool = true) { + storage = AgentSessionStore(rootDirectory: rootDirectory) + if startTicker { + refresh() + let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in + Task { @MainActor in self?.refresh() } + } + RunLoop.main.add(timer, forMode: .common) + ticker = timer + } + } + + deinit { ticker?.invalidate() } + + var visibleSessions: [AgentSession] { + let cutoff = Date().addingTimeInterval(-24 * 60 * 60) + return sessions.filter { $0.updatedAt > cutoff && $0.effectiveStatus() != .closed } + } + var attention: [AgentSession] { visibleSessions.filter { $0.needsAttention() } } + var others: [AgentSession] { visibleSessions.filter { !$0.needsAttention() } } + var workingCount: Int { visibleSessions.filter { $0.effectiveStatus() == .working }.count } + var needsYouCount: Int { attention.count } + var hasCompactActivity: Bool { needsYouCount > 0 || workingCount > 0 } + + func refresh() { + guard !refreshing else { return } + refreshing = true + let storage = storage + queue.async { [weak self] in + let result = Result { try storage.readSessions() } + DispatchQueue.main.async { + guard let self else { return } + self.refreshing = false + switch result { + case .success(let sessions): + // Publish on each tick so stale activity ages out even without a new file. + self.sessions = sessions.sorted { lhs, rhs in + if lhs.needsAttention() != rhs.needsAttention() { return lhs.needsAttention() } + return lhs.updatedAt > rhs.updatedAt + } + self.storageError = nil + case .failure: + self.sessions = [] + self.storageError = "Could not read agent status. Check access to Broschy’s data folder." + } + } + } + } + + func acknowledge(_ session: AgentSession) { + let storage = storage + queue.async { [weak self] in + do { + try storage.acknowledge(provider: session.provider, sessionID: session.sessionID, expectedUpdatedAt: session.updatedAt) + DispatchQueue.main.async { self?.refresh() } + } catch { + DispatchQueue.main.async { self?.storageError = "Could not mark this session as reviewed." } + } + } + } + + func copyResumeCommand(_ session: AgentSession) { + let quote: (String) -> String = { "'" + $0.replacingOccurrences(of: "'", with: "'\\''") + "'" } + let command: String + switch session.provider { + case .codex: command = "codex resume -C \(quote(session.cwd)) -- \(quote(session.sessionID))" + case .claude: command = "cd \(quote(session.cwd)) && claude --resume=\(quote(session.sessionID))" + case .opencode: command = "opencode --session=\(quote(session.sessionID)) -- \(quote(session.cwd))" + } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(command, forType: .string) + } + + #if AGENT_UI_PREVIEW + func setPreviewSessions(_ sessions: [AgentSession]) { self.sessions = sessions } + #endif +} + +struct AgentIntegrationState: Decodable { + var provider: String + var installed: Bool + var message: String +} + +@MainActor +final class AgentIntegrationSetup: ObservableObject { + @Published private(set) var states: [AgentProvider: AgentIntegrationState] = [:] + @Published private(set) var busy: Set = [] + private let queue = DispatchQueue(label: "app.broschy.agent-setup", qos: .utility) + + func refresh() { AgentProvider.allCases.forEach { run("status", provider: $0) } } + + func run(_ action: String, provider: AgentProvider) { + guard !busy.contains(provider) else { return } + guard let resources = Bundle.main.resourceURL, + let executable = Bundle.main.executableURL else { return } + let script = resources.appendingPathComponent("Integrations/agent-integrations.py") + let template = resources.appendingPathComponent("Integrations/opencode/broschy.js") + let cli = executable.deletingLastPathComponent().appendingPathComponent("broschy-cli") + guard FileManager.default.fileExists(atPath: script.path) else { + states[provider] = .init(provider: provider.rawValue, installed: false, + message: "Build and launch Broschy.app to connect tools.") + return + } + busy.insert(provider) + queue.async { [weak self] in + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/python3") + process.arguments = [script.path, action, "--provider", provider.rawValue, + "--cli-path", cli.path, "--template-path", template.path] + let output = Pipe() + process.standardInput = FileHandle.nullDevice + process.standardOutput = output + process.standardError = FileHandle.nullDevice + var result: AgentIntegrationState + do { + try process.run() + // The bundled installer only reads bounded local files. Terminate an unexpected stall. + let timeout = DispatchWorkItem { if process.isRunning { process.terminate() } } + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 15, execute: timeout) + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + timeout.cancel() + guard data.count <= 32_768 else { throw CocoaError(.fileReadCorruptFile) } + result = try JSONDecoder().decode(AgentIntegrationState.self, from: data) + } catch { + result = .init(provider: provider.rawValue, installed: false, + message: "Setup could not finish. Python 3 is required; see the integration guide.") + } + DispatchQueue.main.async { + self?.states[provider] = result + self?.busy.remove(provider) + } + } + } +} diff --git a/Sources/Broschy/AgentsView.swift b/Sources/Broschy/AgentsView.swift new file mode 100644 index 0000000..a6fb775 --- /dev/null +++ b/Sources/Broschy/AgentsView.swift @@ -0,0 +1,293 @@ +import AgentBridge +import AppKit +import SwiftUI + +extension AgentStatus { + var label: String { + switch self { + case .idle: return "Idle" + case .working: return "Working" + case .needsAttention: return "Needs attention" + case .ready: return "Ready to review" + case .error: return "Error" + case .closed: return "Ended" + case .unknown: return "Status unknown" + } + } + var tint: Color { + switch self { + case .working: return Ink.success + case .needsAttention: return Ink.primary + case .ready: return Ink.success + case .error: return Ink.failure + default: return Ink.muted + } + } + var symbol: String { + switch self { + case .working: return "circle.dotted" + case .needsAttention: return "hand.raised.fill" + case .ready: return "checkmark.circle.fill" + case .error: return "exclamationmark.circle.fill" + default: return "circle" + } + } +} + +private extension AgentProvider { + var monogram: String { + switch self { + case .codex: return "C" + case .claude: return "✳︎" + case .opencode: return "o" + } + } +} + +struct AgentMark: View { + let provider: AgentProvider + var body: some View { + Text(provider.monogram) + .font(.system(size: provider == .claude ? 22 : 16, weight: .semibold, design: .rounded)) + .frame(width: 34, height: 34) + .background(Ink.selection, in: RoundedRectangle(cornerRadius: 11)) + .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(Ink.separator, lineWidth: 0.5)) + .accessibilityHidden(true) + } +} + +struct AgentsView: View { + @ObservedObject var monitor: AgentMonitor + @ObservedObject var state: PanelState + @StateObject private var setup = AgentIntegrationSetup() + @State private var showsSetup = false + @State private var copiedID: String? + @State private var copyGeneration = 0 + @Environment(\.flowReduceMotion) var reduceMotion + + var body: some View { + VStack(spacing: 8) { + HStack(spacing: 6) { + Circle().fill(monitor.workingCount > 0 ? Ink.success : Ink.muted.opacity(0.5)) + .frame(width: 5, height: 5) + Text(monitor.workingCount > 0 ? "\(monitor.workingCount) working" : "Agent activity") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Ink.muted) + Spacer() + Button { showsSetup = true } label: { + Label("Connections", systemImage: "slider.horizontal.3") + .font(.system(size: 11, weight: .medium)) + } + .buttonStyle(QuietButtonStyle()) + .popover(isPresented: $showsSetup, arrowEdge: .bottom) { + AgentConnectionsView(setup: setup, monitor: monitor) + } + } + .padding(.top, 12) + + if let error = monitor.storageError { + Label(error, systemImage: "exclamationmark.triangle") + .font(.system(size: 11)).foregroundStyle(Ink.failure) + } + if monitor.visibleSessions.isEmpty { + emptyState + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + if !monitor.attention.isEmpty { + sectionLabel("Needs you", count: monitor.needsYouCount, tint: Ink.primary) + ForEach(monitor.attention) { row($0) } + } + if !monitor.others.isEmpty { + sectionLabel(monitor.attention.isEmpty ? "Sessions" : "Other sessions", count: monitor.others.count, tint: Ink.muted) + .padding(.top, monitor.attention.isEmpty ? 0 : 9) + ForEach(monitor.others) { row($0) } + } + } + .padding(.bottom, 8) + } + .scrollIndicators(.hidden) + } + } + .padding(.bottom, 10) + .onAppear { setup.refresh() } + .onChange(of: showsSetup) { _, presented in state.hasPresentedPopover = presented } + .onChange(of: state.expanded) { _, expanded in if !expanded { showsSetup = false } } + .onDisappear { state.hasPresentedPopover = false } + } + + private var hasConnections: Bool { setup.states.values.contains { $0.installed } } + + private var emptyState: some View { + VStack(spacing: 10) { + HStack(spacing: -5) { + ForEach(AgentProvider.allCases, id: \.self) { provider in + AgentMark(provider: provider) + .rotationEffect(.degrees(provider == .codex ? -10 : provider == .opencode ? 10 : 0)) + .offset(y: provider == .claude ? -6 : 0) + } + } + .padding(.bottom, 3) + Text(hasConnections ? "Waiting for your agents." : "Your agents, in view.") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + Text(hasConnections ? "Start a new session in a connected tool.\nYour activity will appear here." : "See who’s working, waiting for you,\nand ready for a review.") + .font(.system(size: 12)).foregroundStyle(Ink.muted) + .multilineTextAlignment(.center).lineSpacing(3) + Button { showsSetup = true } label: { + Text(hasConnections ? "Manage connections" : "Connect your tools").font(.system(size: 12, weight: .semibold)) + .padding(.horizontal, 18).frame(height: 34) + } + .buttonStyle(FlowButtonStyle(primary: true)).padding(.top, 3) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func sectionLabel(_ title: String, count: Int, tint: Color) -> some View { + HStack(spacing: 6) { + Text(title).font(.system(size: 11, weight: .semibold)) + Text("\(count)").font(.system(size: 9, weight: .semibold, design: .rounded)) + .padding(.horizontal, 5).padding(.vertical, 2) + .background(tint.opacity(0.10), in: Capsule()) + } + .foregroundStyle(tint).padding(.vertical, 4) + } + + private func row(_ session: AgentSession) -> some View { + let status = session.effectiveStatus() + return HStack(spacing: 10) { + AgentMark(provider: session.provider) + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(session.title).font(.system(size: 12, weight: .semibold)).lineLimit(1) + if session.parentSessionID != nil { + Image(systemName: "arrow.turn.down.right").font(.system(size: 9)).foregroundStyle(Ink.muted) + .help("Subagent session") + } + Spacer(minLength: 0) + Text(session.updatedAt, style: .relative) + .font(.system(size: 9, design: .monospaced)).foregroundStyle(Ink.muted) + .lineLimit(1).fixedSize() + } + HStack(spacing: 5) { + Text(session.provider.displayName).foregroundStyle(Ink.muted) + Text("·").foregroundStyle(Ink.muted) + Text(status.label).foregroundStyle(status.tint) + } + .font(.system(size: 10)).lineLimit(1) + if status == .needsAttention || status == .unknown || status == .error { + Text(status == .unknown ? "Activity not confirmed · check your agent" : (status == .error && !session.pendingRequestIDs.isEmpty ? "Response needed in your agent" : session.detail)) + .font(.system(size: 10)).foregroundStyle(Ink.muted).lineLimit(1) + } + } + Menu { + Button(copiedID == session.id ? "Resume command copied" : "Copy resume command") { + monitor.copyResumeCommand(session) + copiedID = session.id + copyGeneration += 1 + let generation = copyGeneration + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + if copyGeneration == generation { copiedID = nil } + } + } + Button("Open project folder") { NSWorkspace.shared.open(URL(fileURLWithPath: session.cwd, isDirectory: true)) } + if status == .ready || status == .error { + Divider() + Button("Mark reviewed") { monitor.acknowledge(session) } + .disabled(session.acknowledgedAt != nil || !session.pendingRequestIDs.isEmpty) + } + } label: { + Image(systemName: copiedID == session.id ? "checkmark" : status.symbol) + .foregroundStyle(status.tint) + .symbolEffect(.pulse, options: .repeating, isActive: state.expanded && status == .working && !reduceMotion) + .font(.system(size: 15)).frame(width: 28, height: 30) + } + .menuStyle(.borderlessButton).menuIndicator(.hidden).fixedSize() + .accessibilityLabel("Actions for \(session.provider.displayName), \(session.title), \(status.label)") + .help("Session actions") + } + .padding(.vertical, 8) + .overlay(alignment: .bottom) { Rectangle().fill(Ink.separator).frame(height: 0.5).padding(.leading, 44) } + } +} + +struct AgentConnectionsView: View { + @ObservedObject var setup: AgentIntegrationSetup + @ObservedObject var monitor: AgentMonitor + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 5) { + Text("Connect your tools").font(.system(size: 17, weight: .semibold, design: .rounded)) + Text("Local status from your coding agents.") + .font(.system(size: 12)).foregroundStyle(Ink.muted) + } + ForEach(AgentProvider.allCases, id: \.self) { provider in + let state = setup.states[provider] + let hasEvents = monitor.sessions.contains { $0.provider == provider && $0.updatedAt > Date().addingTimeInterval(-86400) } + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 10) { + AgentMark(provider: provider) + VStack(alignment: .leading, spacing: 3) { + Text(provider.displayName).font(.system(size: 12, weight: .semibold)) + Text(state?.installed == true ? (hasEvents ? "Events received" : "Configured · awaiting events") : "Not connected") + .font(.system(size: 10)).foregroundStyle(Ink.muted) + } + Spacer() + Button(state?.installed == true ? "Disconnect" : "Connect") { + setup.run(state?.installed == true ? "uninstall" : "install", provider: provider) + } + .buttonStyle(.bordered).controlSize(.small) + .disabled(setup.busy.contains(provider)) + } + if let state, !state.message.isEmpty { + Text(state.message).font(.system(size: 10)).foregroundStyle(Ink.muted) + .fixedSize(horizontal: false, vertical: true) + } + } + } + Divider() + Text("Restart your agent after connecting. In Codex, review Broschy’s hooks with /hooks if prompted. Existing sessions may need a new turn to appear.") + .font(.system(size: 11)).foregroundStyle(Ink.muted) + .fixedSize(horizontal: false, vertical: true) + Button("Integration guide") { + if let guide = Bundle.main.resourceURL?.appendingPathComponent("Integrations/Guide.md") { NSWorkspace.shared.open(guide) } + }.buttonStyle(.link) + .font(.system(size: 11)) + } + .padding(20).frame(width: 350) + .onAppear { setup.refresh() } + } +} + +struct CompactAgentsLeading: View { + @ObservedObject var monitor: AgentMonitor + let reduceMotion: Bool + let isVisible: Bool + var body: some View { + HStack(spacing: 7) { + Image(systemName: monitor.needsYouCount > 0 ? "hand.raised.fill" : "circle.dotted") + .font(.system(size: 16, weight: .medium)) + .symbolEffect(.pulse, options: .repeating, isActive: isVisible && !reduceMotion && monitor.hasCompactActivity) + VStack(alignment: .leading, spacing: 1) { + Text("Agents").font(.system(size: 10, weight: .semibold)) + Text("\(monitor.workingCount) working").font(.system(size: 9)).foregroundStyle(Ink.muted) + } + } + .foregroundStyle(monitor.needsYouCount > 0 ? Ink.primary : Ink.success) + .accessibilityHidden(true) + } +} + +struct CompactAgentsTrailing: View { + @ObservedObject var monitor: AgentMonitor + var body: some View { + VStack(alignment: .leading, spacing: 1) { + Text(monitor.needsYouCount > 0 ? "\(monitor.needsYouCount) need\(monitor.needsYouCount == 1 ? "s" : "") you" : "In progress") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(monitor.needsYouCount > 0 ? Ink.primary : Ink.text) + Text(monitor.attention.first?.title ?? monitor.visibleSessions.first(where: { $0.effectiveStatus() == .working })?.title ?? "Agents") + .font(.system(size: 9)).foregroundStyle(Ink.muted) + } + .lineLimit(1).frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 13) + .accessibilityHidden(true) + } +} diff --git a/Sources/Broschy/App.swift b/Sources/Broschy/App.swift index d3c53c3..662c5e6 100644 --- a/Sources/Broschy/App.swift +++ b/Sources/Broschy/App.swift @@ -13,12 +13,14 @@ final class PanelState: ObservableObject { @Published var reduceMotion = false @Published var reduceTransparency = false @Published var pinned = false + @Published var hasPresentedPopover = false @Published var tab = 0 @Published var notchWidth: CGFloat = 180 @Published var notchHeight: CGFloat = 32 @Published var panelWidth: CGFloat = 448 @Published var showsCompactMusic = false - var compactWidth: CGFloat { min(panelWidth, notchWidth + (showsCompactMusic ? 264 : 128)) } + @Published var showsCompactAgents = false + var compactWidth: CGFloat { min(panelWidth, notchWidth + ((showsCompactMusic || showsCompactAgents) ? 264 : 128)) } @Published var shortcutAvailable = false @Published var selectedMinutes = 25 var open: (() -> Void)? @@ -43,6 +45,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let store = FlowStore() let state = PanelState() let spotify = SpotifyController() + let agents = AgentMonitor() var panel: NotchPanel! var statusItem: NSStatusItem! var observers: [NSObjectProtocol] = [] @@ -52,6 +55,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { var transitionGeneration: UInt = 0 var wantsExpanded = false var pendingCompactMusic = false + var pendingCompactAgents = false var accessibilityObserver: NSObjectProtocol? var hotKey: EventHotKeyRef? var hotKeyHandler: EventHandlerRef? @@ -82,29 +86,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } else { panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] } - panel.contentView = InteractiveHostingView(rootView: NotchRootView(store: store, state: state, spotify: spotify)) + panel.contentView = InteractiveHostingView(rootView: NotchRootView(store: store, state: state, spotify: spotify, agents: agents)) state.open = { [weak self] in self?.expand(focus: true) } state.close = { [weak self] in self?.collapse() } state.hover = { [weak self] inside in self?.hover(inside) } configureMenu() state.selectedMinutes = store.timerState.totalSeconds / 60 if CommandLine.arguments.contains("--music") { state.tab = 3 } + if CommandLine.arguments.contains("--agents") { state.tab = 4 } registerShortcut() updateScreen() panel.orderFrontRegardless() Publishers.CombineLatest4(spotify.$status, spotify.$snapshot, store.$timerState, store.$jobs) - .map { status, track, timer, jobs in + .combineLatest(agents.$sessions) + .receive(on: DispatchQueue.main) + .sink { [weak self] values, sessions in + guard let self else { return } + let (status, track, timer, jobs) = values let recentResult = jobs.contains { job in guard let finished = job.finishedAt else { return false } return Date().timeIntervalSince(finished) < 12 } - return status == .connected && track?.title.isEmpty == false - && !timer.didFinish && !(timer.hasStarted && timer.remainingSeconds > 0) - && !jobs.contains(where: { $0.status == .running }) && !recentResult + let focusOrJob = timer.didFinish || (timer.hasStarted && timer.remainingSeconds > 0) + || jobs.contains(where: { $0.status == .running }) || recentResult + let visible = sessions.filter { $0.updatedAt > Date().addingTimeInterval(-86400) && $0.effectiveStatus() != .closed } + let agentActivity = visible.contains(where: { $0.needsAttention() }) + || (!focusOrJob && visible.contains(where: { $0.effectiveStatus() == .working })) + let music = !agentActivity && !focusOrJob && status == .connected && track?.title.isEmpty == false + self.updateCompactActivity(music: music, agents: agentActivity) } - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] in self?.updateCompactMusic($0) } .store(in: &subscriptions) accessibilityObserver = NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification, object: nil, queue: .main) { [weak self] _ in @@ -119,7 +129,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { }) observers.append(NotificationCenter.default.addObserver(forName: NSWindow.didResignKeyNotification, object: panel, queue: .main) { [weak self] _ in Task { @MainActor in - guard let self, self.wantsExpanded, !self.panel.isKeyWindow, !self.state.pinned, !self.panel.frame.contains(NSEvent.mouseLocation) else { return } + guard let self, self.wantsExpanded, !self.panel.isKeyWindow, !self.state.pinned, !self.state.hasPresentedPopover, !self.panel.frame.contains(NSEvent.mouseLocation) else { return } self.collapse() } }) @@ -263,6 +273,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { transitionWork = nil withoutAnimation { state.showsCompactMusic = pendingCompactMusic + state.showsCompactAgents = pendingCompactAgents state.canvasExpanded = false } // Ordering out releases AppKit/window-server focus correctly; doing this @@ -272,12 +283,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { panel.orderFrontRegardless() } - func updateCompactMusic(_ visible: Bool) { - pendingCompactMusic = visible - // Keep the closing surface's geometry stable; apply a new priority once it settles. + func updateCompactActivity(music: Bool, agents: Bool) { + pendingCompactMusic = music + pendingCompactAgents = agents + // Keep the closing surface stable; apply new activity when it settles. guard wantsExpanded || !state.canvasExpanded else { return } - guard state.showsCompactMusic != visible else { return } - withoutAnimation { state.showsCompactMusic = visible } + guard state.showsCompactMusic != music || state.showsCompactAgents != agents else { return } + withoutAnimation { + state.showsCompactMusic = music + state.showsCompactAgents = agents + } position() } @@ -287,7 +302,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard let self else { return } if inside { self.expand(focus: false) - } else if !self.state.pinned && !self.panel.isKeyWindow { + } else if !self.state.pinned && !self.state.hasPresentedPopover && !self.panel.isKeyWindow { self.collapse() } } diff --git a/Sources/Broschy/Views.swift b/Sources/Broschy/Views.swift index fe6c2b9..59a17f6 100644 --- a/Sources/Broschy/Views.swift +++ b/Sources/Broschy/Views.swift @@ -39,11 +39,12 @@ struct NotchRootView: View { @ObservedObject var store: FlowStore @ObservedObject var state: PanelState @ObservedObject var spotify: SpotifyController + @ObservedObject var agents: AgentMonitor var body: some View { ZStack(alignment: .top) { VStack(spacing: 0) { expandedHeader - ExpandedContent(store: store, state: state, spotify: spotify) + ExpandedContent(store: store, state: state, spotify: spotify, agents: agents) .frame(height: 350) .opacity(state.expanded ? 1 : 0) .offset(y: state.expanded || state.reduceMotion ? 0 : -12) @@ -132,13 +133,16 @@ struct NotchRootView: View { var compact: some View { Button { - if compactTrack != nil { state.tab = 3 } + if state.showsCompactAgents { state.tab = 4 } + else if compactTrack != nil { state.tab = 3 } state.open?() } label: { VStack(spacing: 0) { HStack(spacing: 0) { Group { - if let track = compactTrack { + if state.showsCompactAgents { + CompactAgentsLeading(monitor: agents, reduceMotion: state.reduceMotion, isVisible: !state.expanded) + } else if let track = compactTrack { CompactMusicArtwork(track: track, isVisible: !state.expanded, reduceMotion: state.reduceMotion, height: state.notchHeight) } else { @@ -151,7 +155,9 @@ struct NotchRootView: View { .frame(width: compactWingWidth) Color.clear.frame(width: state.notchWidth) Group { - if let track = compactTrack { + if state.showsCompactAgents { + CompactAgentsTrailing(monitor: agents) + } else if let track = compactTrack { CompactMusicDetails(track: track, isVisible: !state.expanded, reduceMotion: state.reduceMotion, height: state.notchHeight) } else { @@ -187,9 +193,10 @@ struct NotchRootView: View { var compactWingWidth: CGFloat { max(0, (state.compactWidth - state.notchWidth) / 2) } var compactTrack: SpotifySnapshot? { - state.showsCompactMusic && spotify.status == .connected ? spotify.snapshot : nil + !state.showsCompactAgents && state.showsCompactMusic && spotify.status == .connected ? spotify.snapshot : nil } var compactLabel: String { + if state.showsCompactAgents { return "Open Agents. \(agents.workingCount) working, \(agents.needsYouCount) need your attention." } guard let track = compactTrack else { return "Open Broschy. \(compactText)" } return "\(track.isPlaying ? "Playing" : "Paused"): \(track.title) by \(track.artist). Open Music." } @@ -237,12 +244,14 @@ struct ExpandedContent: View { @ObservedObject var store: FlowStore @ObservedObject var state: PanelState @ObservedObject var spotify: SpotifyController + @ObservedObject var agents: AgentMonitor @Environment(\.flowReduceMotion) var reduceMotion @Namespace private var tabSelection var body: some View { VStack(spacing: 0) { HStack(spacing: 3) { + tab("Agents", symbol: "circle.dotted", index: 4) tab("Focus", symbol: "scope", index: 0) tab("Music", symbol: "music.note", index: 3) tab("Signals", symbol: "terminal", index: 1) @@ -257,6 +266,7 @@ struct ExpandedContent: View { case 1: SignalsView(store: store) case 2: LaterView(store: store) case 3: MusicView(spotify: spotify) + case 4: AgentsView(monitor: agents, state: state) default: FocusView(store: store, state: state) } } @@ -273,7 +283,7 @@ struct ExpandedContent: View { .help(store.storageError ?? "") } else { Image(systemName: "lock.shield").font(.system(size: 9)) - Text(state.tab == 3 ? "Spotify on this Mac" : "Stored locally") + Text(state.tab == 3 ? "Spotify on this Mac" : state.tab == 4 ? "Local events · last 24 hours" : "Stored locally") } Spacer() HStack(spacing: 5) { @@ -292,10 +302,10 @@ struct ExpandedContent: View { Button { withAnimation(reduceMotion ? nil : FlowMotion.selection) { state.tab = index } } label: { - HStack(spacing: 5) { - Image(systemName: symbol).font(.system(size: 11)) - Text(title).font(.system(size: 12, weight: .medium)) - if index == 1 && store.jobs.contains(where: { $0.status == .running }) { + HStack(spacing: 4) { + Image(systemName: symbol).font(.system(size: 10)) + Text(title).font(.system(size: 11, weight: .medium)).lineLimit(1) + if (index == 1 && store.jobs.contains(where: { $0.status == .running })) || (index == 4 && agents.needsYouCount > 0) { Circle().fill(Ink.primary).frame(width: 4, height: 4) } } diff --git a/Sources/BroschyCLI/AgentCommand.swift b/Sources/BroschyCLI/AgentCommand.swift new file mode 100644 index 0000000..9091540 --- /dev/null +++ b/Sources/BroschyCLI/AgentCommand.swift @@ -0,0 +1,61 @@ +import AgentBridge +import Darwin +import Foundation + +func agentCommand(_ arguments: [String], store: AgentSessionStore = AgentSessionStore()) -> Int32 { + guard let command = arguments.first else { return agentUsage() } + let rest = Array(arguments.dropFirst()) + if command == "hook" { + // Observation hooks must never reject a tool or emit agent instructions. + // Codex Stop hooks require JSON even when no decision is being made. + defer { print("{}") } + guard rest.count == 2, rest[0] == "--provider", let provider = AgentProvider(rawValue: rest[1]) else { return 0 } + let owner = AgentProcessIdentity.captureOwner(provider: provider) + do { _ = try store.ingest(provider: provider, data: boundedAgentInput(), ownerProcess: owner) } + catch { /* Fail open, without printing private payload/error text. */ } + return 0 + } + if command == "list", rest.isEmpty { + do { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + FileHandle.standardOutput.write(try encoder.encode(store.readSessions())) + print("") + return 0 + } catch { diagnostic("Cannot read agent snapshots."); return 74 } + } + if command == "acknowledge", rest.count == 4, rest[0] == "--provider", + let provider = AgentProvider(rawValue: rest[1]), rest[2] == "--session" { + do { _ = try store.acknowledge(provider: provider, sessionID: rest[3]); return 0 } + catch { diagnostic("Cannot acknowledge this agent session."); return 74 } + } + return agentUsage() +} + +private func agentUsage() -> Int32 { + diagnostic("Expected agent hook --provider codex|claude|opencode, agent list, or agent acknowledge --provider --session .") + return 64 +} + +private func boundedAgentInput() throws -> Data { + let limit = 256 * 1_024 + var result = Data() + // Bound time as well as size: an accidentally open stdin must not hang a hook. + let deadline = DispatchTime.now().uptimeNanoseconds + 800_000_000 + var descriptor = pollfd(fd: STDIN_FILENO, events: Int16(POLLIN | POLLHUP), revents: 0) + while DispatchTime.now().uptimeNanoseconds < deadline { + let remaining = deadline - min(deadline, DispatchTime.now().uptimeNanoseconds) + let ready = poll(&descriptor, 1, Int32(min(100, remaining / 1_000_000))) + if ready < 0 { if errno == EINTR { continue }; throw AgentBridgeError.ioFailure } + if ready == 0 { continue } + if descriptor.revents & Int16(POLLERR | POLLNVAL) != 0 { throw AgentBridgeError.ioFailure } + var buffer = [UInt8](repeating: 0, count: min(8_192, limit + 1 - result.count)) + let count = Darwin.read(STDIN_FILENO, &buffer, buffer.count) + if count == 0 { return result } + if count < 0 { if errno == EINTR { continue }; throw AgentBridgeError.ioFailure } + result.append(contentsOf: buffer.prefix(count)) + guard result.count <= limit else { throw AgentBridgeError.invalidInput } + } + throw AgentBridgeError.busy +} diff --git a/Sources/BroschyCLI/main.swift b/Sources/BroschyCLI/main.swift index 1c778bc..8a3d2c3 100644 --- a/Sources/BroschyCLI/main.swift +++ b/Sources/BroschyCLI/main.swift @@ -24,6 +24,10 @@ func usage() { broschy-cli run [--label \"Tests\"] -- [arguments...] broschy-cli notify --title \"Done\" --status succeeded|failed + broschy-cli agent hook --provider codex|claude|opencode + broschy-cli agent list + broschy-cli agent acknowledge --provider --session + Examples: broschy-cli run --label \"Swift tests\" -- /usr/bin/swift test broschy-cli run --label \"Build\" -- npm run build @@ -188,6 +192,7 @@ if arguments.isEmpty || ["--help", "-h", "help"].contains(arguments[0]) { switch arguments[0] { case "run": exit(runCommand(Array(arguments.dropFirst()))) case "notify": exit(notify(Array(arguments.dropFirst()))) +case "agent": exit(agentCommand(Array(arguments.dropFirst()))) default: diagnostic("Unknown command. Use --help."); exit(64) } #endif diff --git a/Tests/AgentBridgeTests.swift b/Tests/AgentBridgeTests.swift new file mode 100644 index 0000000..bf25960 --- /dev/null +++ b/Tests/AgentBridgeTests.swift @@ -0,0 +1,323 @@ +import AgentBridge +import Darwin +import Dispatch +import Foundation + +// AgentCommand.swift is included so pipe behavior is checked against the exact +// production hook entry point, always with an isolated snapshot directory. +func diagnostic(_ message: String) { FileHandle.standardError.write(Data((message + "\n").utf8)) } + +private func check(_ condition: @autoclosure () throws -> Bool, _ message: String = "Agent bridge check failed") rethrows { + let passed = try condition() + precondition(passed, message) +} + +@main +enum AgentBridgeTests { + static func main() throws { + if (3...4).contains(CommandLine.arguments.count) && CommandLine.arguments[1] == "--hook-child" { + let provider = CommandLine.arguments.count == 4 ? CommandLine.arguments[3] : "codex" + exit(agentCommand(["hook", "--provider", provider], + store: AgentSessionStore(rootDirectory: URL(fileURLWithPath: CommandLine.arguments[2])))) + } + if (4...5).contains(CommandLine.arguments.count) && CommandLine.arguments[1] == "--owner-child" { + // A copied test executable has the provider's actual native binary + // basename, but makes no network/model calls. Its child runs the + // production hook handler to exercise real ancestry capture. + let child = Process() + child.executableURL = URL(fileURLWithPath: CommandLine.arguments[2]) + let provider = CommandLine.arguments.count == 5 ? CommandLine.arguments[4] : "codex" + child.arguments = ["--hook-child", CommandLine.arguments[3], provider] + let input = Pipe(), output = Pipe() + child.standardInput = input; child.standardOutput = output; child.standardError = FileHandle.nullDevice + try child.run() + let event: [String: Any] = ["hook_event_name": provider == "opencode" ? "message.updated" : "UserPromptSubmit", "role": "user", "session_id": "owned-session", + "cwd": "/tmp/owned-project", "owner_pid": 1, + "ownerProcess": ["pid": 1, "executableName": "forged-owner"]] + input.fileHandleForWriting.write(try JSONSerialization.data(withJSONObject: event)) + try input.fileHandleForWriting.close() + let neutral = output.fileHandleForReading.readDataToEndOfFile() + child.waitUntilExit() + guard child.terminationStatus == 0, String(decoding: neutral, as: UTF8.self) == "{}\n" else { exit(2) } + FileHandle.standardOutput.write(Data("ready\n".utf8)) + Thread.sleep(forTimeInterval: 30) + exit(0) + } + guard CommandLine.arguments.count == 2 else { fatalError("Pass an isolated test directory") } + let root = URL(fileURLWithPath: CommandLine.arguments[1]).appendingPathComponent("agent-bridge-" + UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let now = Date(timeIntervalSince1970: 1_000_000) + let secrets = "NEVER-PERSIST-PROMPT-COMMAND-OUTPUT-OR-PERMISSION-SECRET" + + func payload(_ event: String, id: String = "session-1", extra: [String: Any] = [:]) throws -> Data { + var object: [String: Any] = ["hook_event_name": event, "session_id": id, "cwd": "/tmp/my-project", + "prompt": secrets, "tool_input": ["command": secrets], "tool_response": secrets, + "transcript_path": secrets, "title": secrets, "message": secrets, + "permission_suggestions": [["description": secrets]]] + object.merge(extra) { _, value in value } + return try JSONSerialization.data(withJSONObject: object) + } + let store = AgentSessionStore(rootDirectory: root.appendingPathComponent("normal")) + try check(try store.readSessions().isEmpty, "Read-only discovery must tolerate an unconfigured bridge.") + precondition(!FileManager.default.fileExists(atPath: store.rootDirectory.path)) + _ = try store.ingest(provider: .claude, data: payload("SessionStart"), now: now) + var session = try store.ingest(provider: .claude, data: payload("PreToolUse", extra: ["tool_name": secrets]), now: now)! + precondition(session.title == "my-project" && session.detail == "Using a tool") + let files = try FileManager.default.contentsOfDirectory(at: store.sessionsDirectory, includingPropertiesForKeys: nil) + for file in files where file.pathExtension == "json" { + let serialized = String(decoding: try Data(contentsOf: file), as: UTF8.self) + precondition(!serialized.contains(secrets), "Hooks must whitelist metadata before persistence.") + precondition(!serialized.contains("transcript") && !serialized.contains("tool_input")) + } + _ = try store.ingest(provider: .codex, data: payload("SessionStart"), now: now) + _ = try store.ingest(provider: .opencode, data: payload("session.created"), now: now) + try check(try store.readSessions().count == 3, "Provider session IDs must not collide.") + print("PASS: metadata redaction, generic unknown tools, project titles, provider-separated identities") + + let waits = AgentSessionStore(rootDirectory: root.appendingPathComponent("waits")) + _ = try waits.ingest(provider: .opencode, data: payload("permission.asked", extra: ["request_id": "p1"]), now: now) + _ = try waits.ingest(provider: .opencode, data: payload("question.asked", extra: ["request_id": "q1"]), now: now) + session = try waits.ingest(provider: .opencode, data: payload("session.idle"), now: now)! + precondition(session.status == .needsAttention && session.pendingRequestIDs.count == 2) + session = try waits.acknowledge(provider: .opencode, sessionID: "session-1", now: now)! + precondition(session.acknowledgedAt == nil && session.needsAttention(at: now), "Review cannot approve an active request.") + session = try waits.ingest(provider: .opencode, data: payload("permission.replied", extra: ["request_id": "p1"]), now: now)! + precondition(session.status == .needsAttention && session.pendingRequestIDs == ["q1"]) + session = try waits.ingest(provider: .opencode, data: payload("permission.replied", extra: ["request_id": "p1"]), now: now)! + precondition(session.status == .needsAttention && session.pendingRequestIDs == ["q1"]) + session = try waits.ingest(provider: .opencode, data: payload("permission.asked", extra: ["request_id": "p1"]), now: now)! + precondition(session.pendingRequestIDs == ["q1"], "A late duplicate request must not resurrect a replied request.") + session = try waits.ingest(provider: .opencode, data: payload("question.replied", extra: ["request_id": "q1"]), now: now)! + precondition(session.status == .working && session.pendingRequestIDs.isEmpty) + session = try waits.ingest(provider: .opencode, data: payload("session.idle"), now: now)! + precondition(session.status == .ready && session.needsAttention(at: now)) + _ = try waits.acknowledge(provider: .opencode, sessionID: "session-1", now: now) + session = try waits.ingest(provider: .opencode, data: payload("session.status", extra: ["status": "idle"]), now: now)! + precondition(session.acknowledgedAt == now && !session.needsAttention(at: now), "Duplicate idle must preserve review acknowledgment.") + session = try waits.ingest(provider: .opencode, data: payload("question.replied", extra: ["request_id": "q1"]), now: now)! + precondition(session.status == .ready && session.acknowledgedAt == now, "Duplicate reply must not restart a completed turn.") + print("PASS: independent permission/question waits, idempotent replies, no false completion, review acknowledgment") + + let oldResponse = session + _ = try waits.ingest(provider: .opencode, data: payload("message.updated", extra: ["role": "user"]), now: now.addingTimeInterval(0.125)) + _ = try waits.ingest(provider: .opencode, data: payload("session.idle"), now: now.addingTimeInterval(0.250)) + session = try waits.acknowledge(provider: .opencode, sessionID: "session-1", expectedUpdatedAt: oldResponse.updatedAt, now: now.addingTimeInterval(0.375))! + precondition(session.status == .ready && session.acknowledgedAt == nil, "A stale row must not acknowledge a newer unseen response.") + _ = try waits.acknowledge(provider: .opencode, sessionID: "session-1", expectedUpdatedAt: session.updatedAt, now: now.addingTimeInterval(0.375)) + try check(try waits.readSessions().first?.acknowledgedAt != nil) + print("PASS: stale-row acknowledgment does not dismiss newer unseen responses") + + // Use a fresh session so reducer clock-order protection also stays active. + let lifecycle = AgentSessionStore(rootDirectory: root.appendingPathComponent("lifecycle")) + session = try lifecycle.ingest(provider: .opencode, data: payload("session.error"), now: now)! + session = try lifecycle.ingest(provider: .opencode, data: payload("session.idle"), now: now)! + precondition(session.status == .error, "Idle after error must not report success.") + _ = try lifecycle.ingest(provider: .opencode, data: payload("permission.asked", extra: ["request_id": "p2"]), now: now) + session = try lifecycle.ingest(provider: .opencode, data: payload("permission.replied", extra: ["request_id": "p2"]), now: now)! + precondition(session.status == .error, "Resolving a request must not erase the earlier error.") + session = try lifecycle.ingest(provider: .opencode, data: payload("message.updated", extra: ["role": "user"]), now: now)! + precondition(session.status == .working && session.lastFailureAt == nil) + precondition(session.effectiveStatus(at: now.addingTimeInterval(1_801)) == .unknown) + _ = try lifecycle.ingest(provider: .opencode, data: payload("question.asked", extra: ["request_id": "q2"]), now: now) + session = try lifecycle.readSessions().first! + precondition(session.effectiveStatus(at: now.addingTimeInterval(1_801)) == .unknown && !session.needsAttention(at: now.addingTimeInterval(1_801))) + session = try lifecycle.ingest(provider: .opencode, data: payload("session.deleted"), now: now)! + session = try lifecycle.ingest(provider: .opencode, data: payload("session.idle"), now: now)! + precondition(session.status == .closed && session.pendingRequestIDs.isEmpty) + session = try lifecycle.ingest(provider: .opencode, data: payload("message.updated", extra: ["role": "user"]), now: now)! + precondition(session.status == .working) + _ = try lifecycle.ingest(provider: .opencode, data: payload("session.updated", extra: ["cwd": "/tmp/renamed-project"]), now: now) + try check(try lifecycle.readSessions().first?.title == "renamed-project") + print("PASS: sticky errors, new-turn recovery, stale status, closed sessions, metadata-only updates") + + let codex = AgentSessionStore(rootDirectory: root.appendingPathComponent("codex")) + _ = try codex.ingest(provider: .codex, data: payload("PreToolUse", extra: ["tool_name": "request_user_input", "tool_use_id": "tool1"]), now: now) + session = try codex.ingest(provider: .codex, data: payload("Stop"), now: now)! + precondition(session.status == .needsAttention) + session = try codex.ingest(provider: .codex, data: payload("PostToolUse", extra: ["tool_name": "request_user_input", "tool_use_id": "tool1"]), now: now)! + precondition(session.status == .working && session.pendingRequestIDs.isEmpty) + session = try codex.ingest(provider: .codex, data: payload("Interrupt"), now: now)! + precondition(session.status == .idle) + _ = try codex.ingest(provider: .codex, data: payload("StopFailure"), now: now) + session = try codex.ingest(provider: .codex, data: payload("Stop"), now: now)! + precondition(session.status == .error) + print("PASS: Codex user-input waits, tool resumption, interruption, StopFailure preservation") + + let elicitation = AgentSessionStore(rootDirectory: root.appendingPathComponent("elicitation")) + _ = try elicitation.ingest(provider: .claude, data: payload("Elicitation", extra: ["elicitation_id": "elicit-1"]), now: now) + _ = try elicitation.ingest(provider: .claude, data: payload("Elicitation", extra: ["elicitation_id": "elicit-2"]), now: now) + session = try elicitation.ingest(provider: .claude, data: payload("ElicitationResult", extra: ["elicitation_id": "elicit-1", "content": ["private": secrets]]), now: now)! + precondition(session.status == .needsAttention && session.pendingRequestIDs == ["elicit-2"]) + session = try elicitation.ingest(provider: .claude, data: payload("ElicitationResult", extra: ["elicitation_id": "elicit-2", "action": "decline"]), now: now)! + precondition(session.status == .working && session.pendingRequestIDs.isEmpty) + session = try elicitation.ingest(provider: .claude, data: payload("Stop"), now: now)! + precondition(session.status == .ready) + _ = try elicitation.ingest(provider: .claude, data: payload("Elicitation"), now: now) + session = try elicitation.ingest(provider: .claude, data: payload("ElicitationResult"), now: now)! + precondition(session.status == .working && session.pendingRequestIDs.isEmpty) + let elicitationJSON = try Data(contentsOf: elicitation.sessionsDirectory.appendingPathComponent(session.id + ".json")) + precondition(!String(decoding: elicitationJSON, as: UTF8.self).contains(secrets)) + print("PASS: Claude elicitation IDs, independent replies, ID-less results, and private response redaction") + + func rejects(_ operation: () throws -> Void) { + do { try operation(); preconditionFailure("Unsafe input/storage must be rejected") } + catch { } + } + rejects { _ = try store.ingest(provider: .claude, data: payload("SessionStart", id: "../../escaped"), now: now) } + rejects { _ = try store.ingest(provider: .claude, data: payload("SessionStart", extra: ["cwd": "relative/path"]), now: now) } + rejects { _ = try store.ingest(provider: .claude, data: Data(repeating: 32, count: 256 * 1_024 + 1), now: now) } + let symbolic = root.appendingPathComponent("symbolic") + try FileManager.default.createSymbolicLink(at: symbolic, withDestinationURL: store.rootDirectory) + rejects { _ = try AgentSessionStore(rootDirectory: symbolic).ingest(provider: .claude, data: payload("SessionStart"), now: now) } + let unsafe = AgentSessionStore(rootDirectory: root.appendingPathComponent("unsafe")) + _ = try unsafe.ingest(provider: .claude, data: payload("SessionStart"), now: now) + let snapshot = unsafe.sessionsDirectory.appendingPathComponent(AgentSession.stableID(provider: .claude, sessionID: "session-1") + ".json") + try FileManager.default.removeItem(at: snapshot) + try FileManager.default.createSymbolicLink(at: snapshot, withDestinationURL: root.appendingPathComponent("outside")) + rejects { _ = try unsafe.ingest(provider: .claude, data: payload("Stop"), now: now) } + try check(try unsafe.readSessions().isEmpty) + try FileManager.default.removeItem(at: snapshot) + try Data(repeating: 32, count: 64 * 1_024 + 1).write(to: snapshot) + try check(try unsafe.readSessions().isEmpty) + print("PASS: invalid IDs/paths, oversized inputs/snapshots, symlink storage rejection") + + let concurrent = AgentSessionStore(rootDirectory: root.appendingPathComponent("concurrent")) + _ = try concurrent.ingest(provider: .opencode, data: payload("session.created"), now: now) + let lock = NSLock() + var failures = 0 + let requests = try (0..<24).map { try payload("permission.asked", extra: ["request_id": "parallel-\($0)"]) } + DispatchQueue.concurrentPerform(iterations: requests.count) { index in + do { _ = try concurrent.ingest(provider: .opencode, data: requests[index], now: now) } + catch { lock.lock(); failures += 1; lock.unlock() } + } + precondition(failures == 0) + try check(try concurrent.readSessions().first?.pendingRequestIDs.count == requests.count, "Concurrent read-modify-write must not lose pending requests.") + print("PASS: concurrent per-session hook updates retain every pending request") + + let recent = AgentSessionStore(rootDirectory: root.appendingPathComponent("recent")) + for index in 0..<4 { + let record = try recent.ingest(provider: .codex, data: payload("SessionStart", id: "recent-\(index)"), now: now.addingTimeInterval(Double(index)))! + try FileManager.default.setAttributes([.modificationDate: now.addingTimeInterval(Double(index))], + ofItemAtPath: recent.sessionsDirectory.appendingPathComponent(record.id + ".json").path) + } + let visibleRecent = try recent.readSessions(limit: 2) + precondition(visibleRecent.map(\.sessionID) == ["recent-3", "recent-2"], "Bounded discovery must prioritize recent sessions, not arbitrary directory order.") + print("PASS: bounded discovery prioritizes newest session files") + + let ownerExecutable = root.appendingPathComponent("codex-aarch64-apple-darwin") + try FileManager.default.copyItem(at: URL(fileURLWithPath: CommandLine.arguments[0]), to: ownerExecutable) + func launchOwner(_ name: String, executable: URL? = nil, provider: String = "codex") throws -> (Process, AgentSessionStore) { + let target = AgentSessionStore(rootDirectory: root.appendingPathComponent(name)) + let process = Process() + process.executableURL = executable ?? ownerExecutable + process.arguments = ["--owner-child", CommandLine.arguments[0], target.rootDirectory.path, provider] + let output = Pipe() + process.standardOutput = output; process.standardError = FileHandle.nullDevice + try process.run() + var descriptor = pollfd(fd: output.fileHandleForReading.fileDescriptor, events: Int16(POLLIN), revents: 0) + guard poll(&descriptor, 1, 5_000) == 1 else { + process.terminate(); process.waitUntilExit() + fatalError("Fixture owner did not emit its hook within five seconds") + } + let line = output.fileHandleForReading.readData(ofLength: 6) + precondition(String(decoding: line, as: UTF8.self) == "ready\n") + return (process, target) + } + let (owner, ownedStore) = try launchOwner("owner-1") + defer { if owner.isRunning { owner.terminate(); owner.waitUntilExit() } } + let owned = try ownedStore.readSessions().first! + let identity = owned.ownerProcess! + precondition(identity.pid == owner.processIdentifier && identity.executableName == "codex-aarch64-apple-darwin") + precondition(identity.startedAtSeconds > 0 && identity.startedAtMicroseconds < 1_000_000) + precondition(identity.liveness() == .alive && owned.effectiveStatus() == .working) + precondition(AgentProcessIdentity.captureOwner(provider: .claude, startingAt: owner.processIdentifier) == nil, + "A different provider must not adopt this owner.") + let reusedPID = AgentProcessIdentity(pid: identity.pid, startedAtSeconds: identity.startedAtSeconds - 1, + startedAtMicroseconds: identity.startedAtMicroseconds, executableName: identity.executableName) + precondition(reusedPID.liveness() == .exited, "An existing PID with a different start time is not the original owner.") + precondition(owned.effectiveStatus(at: owned.updatedAt.addingTimeInterval(121)) == .unknown, + "A live terminal alone must not imply ongoing work forever.") + var waitingOwner = owned + waitingOwner.status = .needsAttention + waitingOwner.pendingRequestIDs = ["pending-question"] + precondition(waitingOwner.effectiveStatus(at: owned.updatedAt.addingTimeInterval(900)) == .needsAttention, + "A question may legitimately await a response while its exact owner stays alive.") + precondition(waitingOwner.effectiveStatus(at: owned.updatedAt.addingTimeInterval(1_801)) == .unknown, + "A missed reply must not pin the notch indefinitely even if the process remains alive.") + let (unrelatedOwner, _) = try launchOwner("owner-2") + defer { if unrelatedOwner.isRunning { unrelatedOwner.terminate(); unrelatedOwner.waitUntilExit() } } + owner.terminate(); owner.waitUntilExit() + precondition(unrelatedOwner.isRunning && identity.liveness() == .exited) + precondition(owned.effectiveStatus() == .closed && waitingOwner.effectiveStatus() == .closed, + "Owner exit without SessionEnd must immediately clear active work/waits, even with another Codex process open.") + var reviewAfterExit = owned + reviewAfterExit.status = .ready + precondition(reviewAfterExit.effectiveStatus() == .ready) + reviewAfterExit.status = .error + precondition(reviewAfterExit.effectiveStatus() == .error) + let resumedIdentity = AgentProcessIdentity.captureOwner(provider: .codex, startingAt: unrelatedOwner.processIdentifier)! + let resumed = try ownedStore.ingest(provider: .codex, data: payload("UserPromptSubmit", id: "owned-session"), + now: Date(), ownerProcess: resumedIdentity)! + precondition(resumed.ownerProcess == resumedIdentity && resumed.effectiveStatus() == .working, + "Resuming a session must adopt the new invocation's identity.") + let unverified = try ownedStore.ingest(provider: .codex, data: payload("PreToolUse", id: "owned-session"), now: Date())! + precondition(unverified.ownerProcess == nil, "A delivery without verified ancestry must not retain an unrelated old owner.") + precondition(unverified.effectiveStatus(at: unverified.updatedAt.addingTimeInterval(76)) == .unknown) + let spoofed = try ownedStore.ingest(provider: .codex, + data: payload("UserPromptSubmit", id: "spoofed-session", extra: ["owner_pid": unrelatedOwner.processIdentifier, + "ownerProcess": ["pid": unrelatedOwner.processIdentifier, "executableName": "codex-aarch64-apple-darwin"]]), now: Date())! + precondition(spoofed.ownerProcess == nil, "Raw hook payloads must never assign process identity.") + let oldEncoder = JSONEncoder() + oldEncoder.dateEncodingStrategy = .secondsSince1970 + let oldDecoder = JSONDecoder() + oldDecoder.dateDecodingStrategy = .secondsSince1970 + let legacyJSON = try oldEncoder.encode(spoofed) + precondition(!String(decoding: legacyJSON, as: UTF8.self).contains("ownerProcess")) + let legacy = try oldDecoder.decode(AgentSession.self, from: legacyJSON) + precondition(legacy.ownerProcess == nil && legacy.effectiveStatus(at: legacy.updatedAt.addingTimeInterval(76)) == .unknown) + precondition(owned.effectiveStatus(at: owned.updatedAt.addingTimeInterval(76), ownerLiveness: .unknown) == .unknown, + "An indeterminate process lookup must not claim the owner exited or still works.") + let opencodeExecutable = root.appendingPathComponent("opencode.exe") + try FileManager.default.copyItem(at: URL(fileURLWithPath: CommandLine.arguments[0]), to: opencodeExecutable) + let (opencodeOwner, opencodeStore) = try launchOwner("opencode-native", executable: opencodeExecutable, provider: "opencode") + defer { if opencodeOwner.isRunning { opencodeOwner.terminate(); opencodeOwner.waitUntilExit() } } + let opencodeSession = try opencodeStore.readSessions().first! + precondition(opencodeSession.ownerProcess?.pid == opencodeOwner.processIdentifier && opencodeSession.ownerProcess?.executableName == "opencode.exe", + "The installed native OpenCode basename on macOS must be recognized.") + let staleMoment = opencodeSession.updatedAt.addingTimeInterval(121) + let metadataOnly = try opencodeStore.ingest(provider: .opencode, + data: payload("session.updated", id: "owned-session", extra: ["cwd": "/tmp/renamed-owned-project"]), + now: staleMoment, ownerProcess: opencodeSession.ownerProcess)! + precondition(metadataOnly.title == "renamed-owned-project" && metadataOnly.updatedAt == opencodeSession.updatedAt + && metadataOnly.lastEvent == opencodeSession.lastEvent && metadataOnly.effectiveStatus(at: staleMoment) == .unknown, + "Metadata updates must not refresh or revive stale working status.") + print("PASS: real owner exit without SessionEnd, exact PID/start identity, unrelated live owners, bounded freshness, pending waits, resume, payload spoof rejection, legacy snapshots") + + func invokeHook(_ input: Data, keepPipeOpen: Bool = false) throws -> (Int32, String, String, TimeInterval) { + let process = Process() + process.executableURL = URL(fileURLWithPath: CommandLine.arguments[0]) + process.arguments = ["--hook-child", root.appendingPathComponent("cli").path] + let stdin = Pipe(), stdout = Pipe(), stderr = Pipe() + process.standardInput = stdin; process.standardOutput = stdout; process.standardError = stderr + let began = Date() + try process.run() + stdin.fileHandleForWriting.write(input) + if !keepPipeOpen { try stdin.fileHandleForWriting.close() } + let output = stdout.fileHandleForReading.readDataToEndOfFile() + let errors = stderr.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + if keepPipeOpen { try stdin.fileHandleForWriting.close() } + return (process.terminationStatus, String(decoding: output, as: UTF8.self), String(decoding: errors, as: UTF8.self), Date().timeIntervalSince(began)) + } + let success = try invokeHook(payload("Stop")) + precondition(success.0 == 0 && success.1 == "{}\n" && success.2.isEmpty) + let invalid = try invokeHook(Data(("invalid " + secrets).utf8)) + precondition(invalid.0 == 0 && invalid.1 == "{}\n" && invalid.2.isEmpty) + let unclosed = try invokeHook(Data("{".utf8), keepPipeOpen: true) + precondition(unclosed.0 == 0 && unclosed.1 == "{}\n" && unclosed.2.isEmpty && unclosed.3 < 3) + let cliSessions = try AgentSessionStore(rootDirectory: root.appendingPathComponent("cli")).readSessions() + precondition(cliSessions.count == 1 && cliSessions[0].status == .ready) + print("PASS: hook pipes return neutral JSON, fail open, redact errors, and time out bounded stdin") + } +} diff --git a/Tests/test_agent_integrations.py b/Tests/test_agent_integrations.py new file mode 100644 index 0000000..2f22de7 --- /dev/null +++ b/Tests/test_agent_integrations.py @@ -0,0 +1,460 @@ +#!/usr/bin/env python3 +"""Isolated installer and OpenCode adapter checks; no real agent settings or events.""" +import importlib.util +import json +import os +from pathlib import Path +import shlex +import shutil +import stat +import subprocess +import sys +import tempfile +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "agent-integrations.py" +TEMPLATE = ROOT / "Integrations" / "opencode" / "broschy.js" +SPEC = importlib.util.spec_from_file_location("broschy_integrations", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +class InstallerTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="broschy-integrations-") + # macOS /var and /tmp are platform aliases; use the physical test root. + self.base = Path(self.temporary.name).resolve() + self.home = self.base / "home" + self.home.mkdir() + self.cli = self.base / "Broschy's app $(echo inert)" / "broschy-cli" + self.cli.parent.mkdir() + self.cli.write_text("#!/bin/sh\nexit 0\n") + self.cli.chmod(0o751) + self.support = self.home / "Library/Application Support/NotchFlow/agents/integrations" + + def tearDown(self): + self.temporary.cleanup() + + def target(self, provider): + return MODULE.configuration_path(provider, self.home, {}) + + def run_installer(self, action, provider="codex", extra=(), env=None, success=True): + args = [sys.executable, str(SCRIPT), action, "--provider", provider, "--home", str(self.home)] + if action == "install": + args += ["--cli-path", str(self.cli)] + process = subprocess.run(args + list(extra), env=env, text=True, capture_output=True, timeout=10) + self.assertEqual(process.returncode, 0 if success else 1, process.stdout + process.stderr) + self.assertEqual(process.stderr, "") + result = json.loads(process.stdout) + self.assertEqual(set(result), {"provider", "installed", "message"}) + self.assertEqual(result["provider"], provider) + return result + + def put_config(self, provider, config, mode=0o640): + path = self.target(provider) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(config, ensure_ascii=False, indent=4) + "\n") + path.chmod(mode) + return path + + def test_status_is_read_only_when_absent(self): + for provider in MODULE.PROVIDERS: + self.assertFalse(self.run_installer("status", provider)["installed"]) + self.assertEqual(list(self.home.iterdir()), []) + + def test_hooks_merge_idempotent_backup_permissions_and_uninstall(self): + for provider in ("codex", "claude"): + with self.subTest(provider=provider): + original = { + "permissions": {"allow": ["Read"], "deny": ["Bash(rm *)"]}, + "custom": {"unicode": "Café", "enabled": False}, + "hooks": { + "SessionStart": [{"matcher": "startup", "hooks": [{"type": "command", "command": "user-start", "timeout": 7}]}], + "Stop": [], + "CustomEvent": [{"hooks": [{"type": "command", "command": "user-custom"}]}], + }, + } + path = self.put_config(provider, original) + path.parent.chmod(0o750) + before = path.read_bytes() + self.assertTrue(self.run_installer("install", provider)["installed"]) + updated = json.loads(path.read_text()) + self.assertEqual(updated["permissions"], original["permissions"]) + self.assertEqual(updated["custom"], original["custom"]) + self.assertEqual(updated["hooks"]["CustomEvent"], original["hooks"]["CustomEvent"]) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o640) + self.assertEqual(stat.S_IMODE(path.parent.stat().st_mode), 0o750) + for event in MODULE.EVENTS[provider]: + own = updated["hooks"][event][-1]["hooks"][0] + self.assertEqual(shlex.split(own["command"]), [str(self.cli), "agent", "hook", "--provider", provider]) + self.assertEqual(own["timeout"], 2) + self.assertNotIn("async", own) + backup_files = list((self.support / "backups" / provider).iterdir()) + self.assertEqual(len(backup_files), 1) + self.assertEqual(backup_files[0].read_bytes(), before) + self.assertEqual(stat.S_IMODE(backup_files[0].stat().st_mode), 0o600) + stable = path.read_bytes() + inode = path.stat().st_ino + self.run_installer("install", provider) + self.assertEqual(path.read_bytes(), stable) + self.assertEqual(path.stat().st_ino, inode) + self.assertEqual(len(list((self.support / "backups" / provider).iterdir())), 1) + self.assertTrue(self.run_installer("status", provider)["installed"]) + self.assertFalse(self.run_installer("uninstall", provider)["installed"]) + self.assertEqual(json.loads(path.read_text()), original) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o640) + self.assertFalse((self.support / (provider + ".json")).exists()) + self.assertFalse(self.run_installer("uninstall", provider)["installed"]) + + def test_status_requires_an_available_executable_and_detects_app_moves(self): + for provider in MODULE.PROVIDERS: + with self.subTest(provider=provider): + self.cli.chmod(0o751) + self.run_installer("install", provider) + path = self.target(provider) + before = path.read_bytes() + manifest_before = (self.support / (provider + ".json")).read_bytes() + self.cli.chmod(0o600) + unavailable = self.run_installer("status", provider) + self.assertFalse(unavailable["installed"]) + self.assertIn("Reconnect", unavailable["message"]) + self.cli.chmod(0o751) + moved = self.base / (provider + "-moved-cli") + shutil.copy2(self.cli, moved) + moved_status = self.run_installer("status", provider, extra=("--cli-path", str(moved))) + self.assertFalse(moved_status["installed"]) + self.assertIn("Reconnect", moved_status["message"]) + hidden = self.cli.with_name("temporarily-absent-cli") + self.cli.rename(hidden) + self.assertFalse(self.run_installer("status", provider)["installed"]) + hidden.rename(self.cli) + self.assertEqual(path.read_bytes(), before) + self.assertEqual((self.support / (provider + ".json")).read_bytes(), manifest_before) + self.assertTrue(self.run_installer("status", provider, extra=("--cli-path", str(self.cli)))["installed"]) + self.run_installer("install", provider, extra=("--cli-path", str(moved))) + self.assertTrue(self.run_installer("status", provider, extra=("--cli-path", str(moved)))["installed"]) + + def test_codex_result_requires_hook_review_and_does_not_edit_trust(self): + config = self.home / ".codex/config.toml" + config.parent.mkdir() + original = b'[hooks_trust]\ntrusted_hashes = ["unchanged"]\n' + config.write_bytes(original) + result = self.run_installer("install") + self.assertIn("/hooks", result["message"]) + self.assertEqual(config.read_bytes(), original) + + def test_fresh_configs_removed_on_uninstall(self): + for provider in MODULE.PROVIDERS: + self.run_installer("install", provider) + self.run_installer("uninstall", provider) + self.assertFalse(self.target(provider).exists()) + + def test_uninstall_preserves_new_settings_and_handlers_in_our_group(self): + self.run_installer("install", "claude") + path = self.target("claude") + value = json.loads(path.read_text()) + value["model"] = "user-model" + added = {"type": "command", "command": "my-new-hook"} + value["hooks"]["Stop"][0]["hooks"].append(added) + value["hooks"]["SessionEnd"].append({"matcher": "other", "hooks": [added]}) + path.write_text(json.dumps(value)) + self.run_installer("uninstall", "claude") + self.assertEqual(json.loads(path.read_text()), { + "model": "user-model", "hooks": {"Stop": [{"hooks": [added]}], "SessionEnd": [{"matcher": "other", "hooks": [added]}]}, + }) + + def test_reinstall_can_update_executable_without_duplicate_hooks(self): + self.run_installer("install") + moved = self.base / "Moved Broschy" / "broschy-cli" + moved.parent.mkdir() + shutil.copy2(self.cli, moved) + self.run_installer("install", extra=("--cli-path", str(moved))) + value = json.loads(self.target("codex").read_text()) + for groups in value["hooks"].values(): + self.assertEqual(len(groups), 1) + self.assertEqual(shlex.split(groups[0]["hooks"][0]["command"])[0], str(moved)) + self.run_installer("uninstall") + self.assertFalse(self.target("codex").exists()) + + def test_missing_owned_hook_can_be_repaired(self): + self.run_installer("install") + path = self.target("codex") + value = json.loads(path.read_text()) + del value["hooks"]["Stop"] + path.write_text(json.dumps(value)) + self.assertFalse(self.run_installer("status")["installed"]) + self.assertTrue(self.run_installer("install")["installed"]) + self.assertTrue(self.run_installer("status")["installed"]) + + def test_modified_owned_hooks_are_preserved(self): + self.run_installer("install") + path = self.target("codex") + value = json.loads(path.read_text()) + value["hooks"]["Stop"][0]["hooks"][0]["timeout"] = 15 + before = json.dumps(value).encode() + path.write_bytes(before) + result = self.run_installer("uninstall", success=False) + self.assertIn("edited", result["message"]) + self.assertEqual(path.read_bytes(), before) + self.assertTrue((self.support / "codex.json").exists()) + + def test_matching_unowned_hook_is_not_adopted(self): + handler = MODULE.handler_for(self.cli, "codex") + path = self.put_config("codex", {"hooks": {"Stop": [{"hooks": [handler]}]}}) + before = path.read_bytes() + self.run_installer("install", success=False) + self.assertEqual(path.read_bytes(), before) + self.assertFalse((self.support / "codex.json").exists()) + + def test_duplicate_owned_hook_is_not_ambiguously_removed(self): + self.run_installer("install") + path = self.target("codex") + value = json.loads(path.read_text()) + value["hooks"]["Stop"].append(value["hooks"]["Stop"][0]) + before = json.dumps(value).encode() + path.write_bytes(before) + self.run_installer("uninstall", success=False) + self.assertEqual(path.read_bytes(), before) + + def test_home_ignores_real_agent_environment_overrides(self): + outside = self.base / "outside" + outside.mkdir() + sentinel = outside / "settings.json" + sentinel.write_text('{"never":"change"}') + env = dict(os.environ, CODEX_HOME=str(outside), CLAUDE_CONFIG_DIR=str(outside), XDG_CONFIG_HOME=str(outside)) + for provider in MODULE.PROVIDERS: + self.run_installer("install", provider, env=env) + self.assertTrue(self.target(provider).exists()) + self.assertEqual(sorted(path.name for path in outside.iterdir()), ["settings.json"]) + self.assertEqual(sentinel.read_text(), '{"never":"change"}') + + def test_configuration_paths_honor_environment_when_not_isolated(self): + overrides = {"CODEX_HOME": str(self.base / "codex-env"), "CLAUDE_CONFIG_DIR": str(self.base / "claude-env"), "XDG_CONFIG_HOME": str(self.base / "xdg-env")} + self.assertEqual(MODULE.configuration_path("codex", self.home, overrides), self.base / "codex-env/hooks.json") + self.assertEqual(MODULE.configuration_path("claude", self.home, overrides), self.base / "claude-env/settings.json") + self.assertEqual(MODULE.configuration_path("opencode", self.home, overrides), self.base / "xdg-env/opencode/plugins/broschy.js") + with self.assertRaises(MODULE.IntegrationError): + MODULE.configuration_path("codex", self.home, {"CODEX_HOME": "relative"}) + + def test_malformed_or_oversize_configs_are_never_replaced(self): + inputs = [b"not json", b"[]", b'{"hooks": []}', b'{"hooks":{"Stop":{}}}', b'{"hooks":{"Stop":[{}]}}', + b'{"a":1,"a":2}', b'{"value":NaN}', b" " * (MODULE.MAX_CONFIG_BYTES + 1)] + path = self.target("codex") + path.parent.mkdir() + for original in inputs: + with self.subTest(original=original[:40]): + path.write_bytes(original) + self.run_installer("install", success=False) + self.assertEqual(path.read_bytes(), original) + self.assertFalse((self.support / "codex.json").exists()) + + def test_symlink_configuration_file_refused(self): + outside = self.base / "outside.json" + outside.write_text('{"untouched":true}') + path = self.target("codex") + path.parent.mkdir() + path.symlink_to(outside) + self.run_installer("install", success=False) + self.assertTrue(path.is_symlink()) + self.assertEqual(outside.read_text(), '{"untouched":true}') + + def test_symlink_ancestor_refused(self): + outside = self.base / "outside" + outside.mkdir() + (self.home / ".codex").symlink_to(outside, target_is_directory=True) + self.run_installer("install", success=False) + self.assertEqual(list(outside.iterdir()), []) + + def test_symlink_ownership_path_refused(self): + outside = self.base / "outside" + outside.mkdir() + (self.home / "Library").symlink_to(outside, target_is_directory=True) + self.run_installer("install", success=False) + self.assertFalse(self.target("codex").exists()) + self.assertEqual(list(outside.iterdir()), []) + + def test_directory_config_and_relative_cli_refused(self): + self.target("codex").mkdir(parents=True) + self.run_installer("install", success=False) + self.assertTrue(self.target("codex").is_dir()) + self.run_installer("install", "claude", extra=("--cli-path", "relative/broschy-cli"), success=False) + self.assertFalse(self.target("claude").exists()) + + def test_uninstall_does_not_recreate_a_config_deleted_by_the_user(self): + self.put_config("codex", {"custom": "preserve if present"}) + self.run_installer("install") + self.target("codex").unlink() + self.run_installer("uninstall") + self.assertFalse(self.target("codex").exists()) + + def test_opencode_install_update_backup_and_uninstall(self): + result = self.run_installer("install", "opencode") + self.assertTrue(result["installed"]) + path = self.target("opencode") + original = path.read_bytes() + self.assertIn(("const CLI_PATH = " + json.dumps(str(self.cli)) + ";").encode(), original) + self.assertNotIn(b"__BROSCHY_CLI_PATH__", original) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + self.run_installer("install", "opencode") + self.assertEqual(path.read_bytes(), original) + self.assertFalse((self.support / "backups/opencode").exists()) + moved = self.base / "new-cli" + shutil.copy2(self.cli, moved) + self.run_installer("install", "opencode", extra=("--cli-path", str(moved))) + files = list((self.support / "backups/opencode").iterdir()) + self.assertEqual(files[0].read_bytes(), original) + self.assertTrue(self.run_installer("status", "opencode")["installed"]) + self.run_installer("uninstall", "opencode") + self.assertFalse(path.exists()) + + def test_opencode_unowned_or_edited_plugin_preserved(self): + path = self.target("opencode") + path.parent.mkdir(parents=True) + path.write_text("// user plugin\n") + self.run_installer("install", "opencode", success=False) + self.assertEqual(path.read_text(), "// user plugin\n") + path.unlink() + self.run_installer("install", "opencode") + with path.open("a") as stream: + stream.write("\n// user edit\n") + before = path.read_bytes() + self.assertFalse(self.run_installer("status", "opencode")["installed"]) + self.run_installer("uninstall", "opencode", success=False) + self.assertEqual(path.read_bytes(), before) + + def test_bundled_template_override_and_invalid_template(self): + template = self.base / "template.js" + shutil.copyfile(TEMPLATE, template) + self.run_installer("install", "opencode", extra=("--template-path", str(template))) + self.run_installer("uninstall", "opencode") + template.write_text("// no placeholder") + self.run_installer("install", "opencode", extra=("--template-path", str(template)), success=False) + self.assertFalse(self.target("opencode").exists()) + + +@unittest.skipUnless(shutil.which("node"), "Node.js is optional; required only for OpenCode adapter verification") +class OpenCodeAdapterTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="broschy-opencode-") + self.base = Path(self.temporary.name).resolve() + self.capture = self.base / "events.jsonl" + self.cli = self.base / "broschy's cli $(inert)" + self.cli.write_text("#!" + sys.executable + "\n" + textwrap.dedent(''' + import json, os, sys, time + value = json.load(sys.stdin) + with open(os.environ["BROSCHY_TEST_CAPTURE"], "a") as stream: + stream.write(json.dumps({"args":sys.argv[1:], "payload":value}) + "\\n") + if value["session_id"] == "slow-session": + time.sleep(8) + with open(os.environ["BROSCHY_TEST_CAPTURE"] + ".late", "w") as stream: + stream.write("should have timed out") + ''')) + self.cli.chmod(0o700) + self.plugin = self.base / "broschy.mjs" + self.plugin.write_bytes(MODULE.plugin_bytes(str(TEMPLATE), self.cli)) + + def tearDown(self): + self.temporary.cleanup() + + def run_node(self, events, expected, fallback="/fallback", timeout_ms=4000): + runner = self.base / "runner.mjs" + runner.write_text(textwrap.dedent(''' + import { readFileSync, existsSync } from "node:fs"; + import { BroschyPlugin } from "./broschy.mjs"; + const plugin = await BroschyPlugin({directory: FALLBACK}); + const events = EVENTS; + const start = Date.now(); + for (const event of events) await plugin.event({event}); + const callbackMilliseconds = Date.now() - start; + while (Date.now() - start < TIMEOUT) { + const count = existsSync(process.env.BROSCHY_TEST_CAPTURE) + ? readFileSync(process.env.BROSCHY_TEST_CAPTURE, "utf8").trim().split("\\n").length : 0; + if (count >= EXPECTED) break; + await new Promise(resolve => setTimeout(resolve, 25)); + } + await new Promise(resolve => setTimeout(resolve, 100)); + process.stdout.write(JSON.stringify({callbackMilliseconds})); + ''').replace("FALLBACK", json.dumps(fallback)).replace("EVENTS", json.dumps(events)).replace("TIMEOUT", str(timeout_ms)).replace("EXPECTED", str(expected))) + process = subprocess.run([shutil.which("node"), str(runner)], capture_output=True, text=True, + env=dict(os.environ, BROSCHY_TEST_CAPTURE=str(self.capture)), timeout=8) + self.assertEqual(process.returncode, 0, process.stderr) + self.assertEqual(process.stderr, "") + result = json.loads(process.stdout) + records = [json.loads(line) for line in self.capture.read_text().splitlines()] if self.capture.exists() else [] + self.assertEqual(len(records), expected) + for record in records: + self.assertEqual(record["args"], ["agent", "hook", "--provider", "opencode"]) + return result, [record["payload"] for record in records] + + def test_event_mapping_uses_only_safe_metadata_and_preserves_session_cwd(self): + events = [ + {"type": "session.created", "properties": {"info": {"id": "s1", "directory": "/project-one", "parentID": "parent", "title": "SECRET title"}}}, + {"type": "session.updated", "properties": {"info": {"id": "s1", "directory": "/project-renamed", "title": "SECRET updated"}}}, + {"type": "session.created", "properties": {"info": {"id": "s2", "directory": "/project-two"}}}, + {"type": "session.status", "properties": {"sessionID": "s1", "status": {"type": "busy", "message": "SECRET status"}}}, + {"type": "permission.asked", "properties": {"sessionID": "s1", "id": "permission-1", "permission": "bash", "patterns": ["SECRET input"]}}, + {"type": "question.asked", "properties": {"sessionID": "s1", "id": "question-1", "questions": ["SECRET question"]}}, + {"type": "permission.replied", "properties": {"sessionID": "s1", "requestID": "permission-1", "reply": "SECRET approval"}}, + {"type": "question.replied", "properties": {"sessionID": "s1", "requestID": "question-1", "answers": ["SECRET answer"]}}, + {"type": "session.idle", "properties": {"sessionID": "s2"}}, + {"type": "session.error", "properties": {"sessionID": "s1", "error": {"data": "SECRET error"}}}, + {"type": "message.updated", "properties": {"info": {"sessionID": "s1", "role": "user", "id": "SECRET-message-id", "body": "SECRET prompt"}}}, + {"type": "message.updated", "properties": {"info": {"sessionID": "s1", "role": "assistant", "body": "SECRET output"}}}, + {"type": "session.error", "properties": {"error": {"message": "SECRET global"}}}, + ] + result, payloads = self.run_node(events, 11) + self.assertLess(result["callbackMilliseconds"], 500) + allowed = {"hook_event_name", "session_id", "cwd", "request_id", "status", "tool_name", "parent_session_id", "role"} + self.assertTrue(all(set(payload) <= allowed for payload in payloads)) + self.assertNotIn("SECRET", self.capture.read_text()) + self.assertEqual(payloads[3]["cwd"], "/project-renamed") + self.assertEqual(payloads[3]["parent_session_id"], "parent") + self.assertEqual(payloads[4]["request_id"], "permission-1") + self.assertEqual(payloads[4]["tool_name"], "bash") + self.assertEqual(payloads[5]["request_id"], "question-1") + self.assertEqual(payloads[6]["request_id"], "permission-1") + self.assertEqual(payloads[7]["request_id"], "question-1") + self.assertEqual(payloads[8]["cwd"], "/project-two") + self.assertEqual(payloads[10]["role"], "user") + + def test_fallback_directory_validation_and_unknown_events(self): + events = [ + {"type": "session.status", "properties": {"sessionID": "new", "status": {"type": "retry"}}}, + {"type": "question.rejected", "properties": {"sessionID": "new", "requestID": "q2"}}, + {"type": "session.status", "properties": {"sessionID": "bad\nID", "status": {"type": "busy"}}}, + {"type": "session.status", "properties": {"sessionID": "new", "status": {"type": "secret"}}}, + {"type": "permission.asked", "properties": {"sessionID": "new", "id": "p2", "permission": "SECRET custom tool"}}, + {"type": "question.replied", "properties": {"sessionID": "new", "requestID": "bad ID"}}, + {"type": "arbitrary.event", "properties": {"sessionID": "new"}}, + {"type": "session.status", "properties": []}, + None, + ] + _, payloads = self.run_node(events, 3) + self.assertTrue(all(value["cwd"] == "/fallback" for value in payloads)) + self.assertNotIn("tool_name", payloads[-1]) + self.assertNotIn("SECRET", self.capture.read_text()) + + def test_slow_bridge_is_killed_and_next_event_is_delivered(self): + events = [ + {"type": "session.idle", "properties": {"sessionID": "slow-session"}}, + {"type": "session.idle", "properties": {"sessionID": "next-session"}}, + ] + result, payloads = self.run_node(events, 2, timeout_ms=4500) + self.assertLess(result["callbackMilliseconds"], 500) + self.assertEqual([value["session_id"] for value in payloads], ["slow-session", "next-session"]) + self.assertFalse(Path(str(self.capture) + ".late").exists()) + + def test_missing_cli_is_fail_open(self): + self.cli.unlink() + result, payloads = self.run_node([{"type": "session.idle", "properties": {"sessionID": "s1"}}], 0) + self.assertLess(result["callbackMilliseconds"], 500) + self.assertEqual(payloads, []) + + +if __name__ == "__main__": + if not shutil.which("node"): + print("SKIP: Node.js is unavailable; OpenCode adapter subprocess tests were not run.", file=sys.stderr) + unittest.main() diff --git a/build.sh b/build.sh index da0af34..eeb5928 100644 --- a/build.sh +++ b/build.sh @@ -12,6 +12,10 @@ cp "$bin_dir/broschy-cli" "$app_dir/Contents/MacOS/broschy-cli" cp Info.plist "$app_dir/Contents/Info.plist" cp Resources/Broschy.icns "$app_dir/Contents/Resources/Broschy.icns" cp Resources/AppIcon.png "$app_dir/Contents/Resources/AppIcon.png" +mkdir -p "$app_dir/Contents/Resources/Integrations/opencode" +cp scripts/agent-integrations.py "$app_dir/Contents/Resources/Integrations/agent-integrations.py" +cp Integrations/opencode/broschy.js "$app_dir/Contents/Resources/Integrations/opencode/broschy.js" +cp docs/AGENTS.md "$app_dir/Contents/Resources/Integrations/Guide.md" codesign --force --sign - "$app_dir/Contents/MacOS/broschy-cli" codesign --force --sign - "$app_dir" printf '%s\n' "$app_dir" diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..fcb93a0 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,87 @@ +# Agents + +Broschy can monitor local Codex, Claude Code, and OpenCode sessions from the notch. It shows which projects have activity and which sessions need a response or review. This integration does not send prompts, approve tools, or stop agents. + +## Connect + +Build and launch `Broschy.app`, then open **Agents → Connections** and connect the tools you use. The setup helper requires Python 3 (`/usr/bin/python3`, available with the developer tools used to build Broschy). + +- **Codex:** Broschy adds command hooks to your user `hooks.json`. Restart Codex and review the new hooks through `/hooks` when required. Broschy never changes Codex's hook trust records. This adapter targets Codex CLI. Monitoring conversations in the Codex desktop app is not currently verified. Configuration alone does not mean that an existing session is being observed. +- **Claude Code:** Broschy merges command hooks into your user `settings.json`. Restart Claude Code after connecting. +- **OpenCode:** Broschy installs a local JavaScript plugin. Restart OpenCode so it loads the plugin. Sessions running with plugins disabled will not send events. + +Once a session emits an event, it appears in Agents. A configured connection with no events stays **Configured · awaiting events**. Existing conversations are not imported. You do not need API keys or a new account in Broschy. + +Broschy installs a reference to the CLI inside your current app bundle. If you move the app, reconnect the integration from its new location. + +## Read the panel + +| Status | Meaning | +| :--- | :--- | +| Working | A recent prompt, tool call, or busy event was observed. Activity expires without new evidence, even when the process is still open. | +| Needs attention | The agent reported a permission request or a question. Respond in the original tool. | +| Ready to review | The agent stopped responding or became idle. Review the result; this does not certify that the task succeeded. | +| Error | A supported failure event was observed. Broschy shows a generic error, without recording the error contents. | +| Idle | The session started or was interrupted. | +| Status unknown | Working activity has not been refreshed for 120 seconds with a verified live process, or 75 seconds without one. Check the original tool. An unanswered request becomes unknown after 30 minutes even if its process remains alive. | + +**Needs you** gathers questions, permission requests, unreviewed responses, and errors. Use the status icon at the right of a session for **Copy resume command**, **Open project folder**, or **Mark reviewed**. Paste a copied resume command in your terminal to resume the corresponding provider's session. This does not select a running terminal tab or jump to a particular desktop conversation. + +Marking a response or error reviewed only clears its attention badge in Broschy. It does not change agent state. An active permission request cannot be marked reviewed. When its originating process exits, an active session is hidden even if no exit hook arrived. Completed responses and errors remain available for review. Ended sessions are hidden; the panel shows sessions updated in the last 24 hours, from the 512 most recently modified snapshots. + +In the compact notch, attention takes priority over focus, command results, and music. Working agents appear when no focus session or recent command result needs that space. The animation indicates activity, not token throughput or percent complete. It respects Reduce Motion. + +## What is stored + +Hooks pass events to `broschy-cli agent hook`. Codex and Claude may include conversation content in their hook payloads; the CLI transiently receives bounded JSON and immediately drops fields outside its metadata allowlist before atomically updating a local session snapshot. It does not save stdin or transcripts. Provider names are part of session identities, so sessions with the same ID in different tools remain separate. + +Saved fields include provider, session ID, working directory, project folder name, status, generic activity label, timestamps, parent session ID when available, opaque pending request IDs, and the originating process ID, start time, and executable name when identifiable. Process start time prevents a reused PID from keeping an old session active; no process arguments or executable paths are stored. Prompts, conversation titles, tool arguments, output, permission descriptions, answers, and credentials are not recorded. + +```text +~/Library/Application Support/NotchFlow/agents/ +├── sessions/ # Per-session status snapshots +└── integrations/ # Setup ownership records and configuration backups +``` + +Configuration backups contain the original tool settings, which may contain private configuration. They stay on your Mac and should not be shared or committed. No local network listener, cloud sync, analytics, or model requests are involved. + +## Configuration and removal + +Default locations: + +| Tool | Configuration | +| :--- | :--- | +| Codex | `~/.codex/hooks.json` (`CODEX_HOME` is honored) | +| Claude Code | `~/.claude/settings.json` (`CLAUDE_CONFIG_DIR` is honored) | +| OpenCode | `~/.config/opencode/plugins/broschy.js` (`XDG_CONFIG_HOME` is honored) | + +Environment overrides apply to the process running setup. A GUI app may not inherit your shell environment; use the command-line installer below for a custom configuration directory. + +Setup preserves unrelated settings and hooks, makes backups, and refuses malformed JSON or unsafe file locations. **Disconnect** removes Broschy's own entries; restart the affected tool afterward. It leaves session history and backup files in Broschy's data folder. + +From the repository: + +```sh +python3 scripts/agent-integrations.py install --provider codex \ + --cli-path "$PWD/build/Broschy.app/Contents/MacOS/broschy-cli" + +# Replace codex with claude or opencode for the other integrations. +python3 scripts/agent-integrations.py status --provider codex \ + --cli-path "$PWD/build/Broschy.app/Contents/MacOS/broschy-cli" + +python3 scripts/agent-integrations.py uninstall --provider codex \ + --cli-path "$PWD/build/Broschy.app/Contents/MacOS/broschy-cli" + +# Read normalized snapshots locally. +./build/Broschy.app/Contents/MacOS/broschy-cli agent list +``` + +## Scope and limitations + +This first version monitors events going forward. It cannot attach to every already running session, guarantee event delivery after a force quit, or prove that an open process is currently working. It checks the identity of the specific originating process, not whether any process with the same provider name is running. Older snapshots without a process identity use the shorter activity expiry. Tools that disable hooks/plugins or use different configuration paths will not be visible. A stopped response may be followed by more work from another hook. + +Codex and Claude subagent lifecycle callbacks are not used to mark the parent finished. OpenCode sessions with an explicit parent ID can be shown as child sessions. Tool compatibility depends on installed versions and their event support; the adapters were developed against Codex CLI 0.144.5, Claude Code 2.1.197, and OpenCode 1.17.15. Codex CLI 0.144.5 supports `Stop` after a completed response, but does not implement `SessionEnd` or `Interrupt` hooks. Its process identity therefore provides the exit fallback; configured keys for those events remain available for newer versions. An installed configuration is not an end-to-end compatibility guarantee. See the [0.144.5 event registry](https://github.com/openai/codex/blob/rust-v0.144.5/codex-rs/hooks/src/lib.rs#L18-L30). + +Full task creation, sending follow-up prompts, cancellation, and approvals inside Broschy are outside this version. Those need separate provider control APIs and explicit ownership of the sessions being controlled. + +Official integration references: [Codex hooks](https://learn.chatgpt.com/docs/hooks), [Claude Code hooks](https://code.claude.com/docs/en/hooks), [OpenCode plugins](https://opencode.ai/docs/plugins/). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2be7a51..2790527 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -6,11 +6,14 @@ Broschy uses Swift Package Manager, SwiftUI for views, and AppKit for a borderle ```text Sources/Broschy/ App, panel behavior, views, storage, Spotify -Sources/BroschyCLI/ Command wrapper and explicit result notifications -Tests/ Isolated core/CLI/store checks and Spotify fixtures +Sources/BroschyCLI/ Command wrapper, result notifications, agent hook entry point +Sources/AgentBridge/ Normalized agent sessions, process identity, atomic snapshots +Integrations/opencode/ Metadata-only OpenCode event adapter +scripts/ Reversible agent configuration installer +Tests/ Core, CLI, agent lifecycle, installer, and Spotify checks Resources/ App icon and macOS ICNS bundle build.sh Assemble and locally sign the app -test.sh Run both test executables in a temporary directory +test.sh Run Swift and Python/Node checks with isolated data ``` `bash build.sh` uses `.build/` by default. Set `BROSCHY_BUILD_CACHE` to put the Swift build cache elsewhere. `NOTCHFLOW_BUILD_CACHE` remains accepted for older local scripts. Build output is always under `build/` and is ignored by Git. @@ -32,9 +35,17 @@ The app sends native Apple Events to the running Spotify process after an explic Track fields are read individually: Spotify can reject aggregate property reads with error `-10000` because its legacy `starred` field is unavailable. Missing optional metadata does not disable playback controls. Album images are fetched from validated HTTPS artwork hosts returned by Spotify. +## Agent activity + +The bridge stores the last observed event, while `effectiveStatus` derives the current display state. Hook capture walks a bounded ancestor chain and records only the originating provider process ID, start time, and executable name. Checking that exact identity avoids confusing another terminal session, a reused PID, or the shared desktop app-server with the original process. + +An exited process closes active sessions immediately without relying on an exit hook. Unverified activity expires after 75 seconds; verified-live working activity expires after 120 seconds; verified-live waiting requests expire after 30 minutes. Expiry means unknown, never success. Ready responses and errors remain reviewable after process exit. Metadata-only updates do not refresh activity freshness. + +Codex CLI 0.144.5 does not dispatch SessionEnd/Interrupt hooks. Keep process checks and age limits even when newer provider versions expose more events. Existing snapshots without process identity remain readable and use the unverified fallback. See [Agents](AGENTS.md) for installation and privacy details. + ## Verification -`bash test.sh` checks timer deadlines and sleep, pause/resume, persisted state and recovery, CLI exit status and cancellation, metadata normalization, Apple Event encoding, error handling, request ordering, and stale response rejection. It uses separate temporary directories and mocked Spotify responses. +`bash test.sh` checks timer deadlines and sleep, pause/resume, persisted state and recovery, CLI exit status and cancellation, metadata normalization, Apple Event encoding, error handling, request ordering, and stale response rejection. It uses separate temporary directories and mocked Spotify responses. Agent checks cover redaction, concurrent requests, stale acknowledgments, process exit without a terminal event, PID reuse, activity expiry, and provider-specific event normalization. Installer tests preserve existing configuration; Node checks exercise the OpenCode plugin without model requests. GitHub Actions runs those checks, builds the release bundle, validates its property list, verifies the local signature, and exercises the CLI help path on a macOS 26 runner. It does not test a physical notch or connect to Spotify. diff --git a/docs/assets/agents-dark.png b/docs/assets/agents-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..12ce144a2ec3c9589ef701d5e8aa329f952bb448 GIT binary patch literal 29478 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 limit: + raise IntegrationError("A configuration or integration file exceeds the size limit.") + with os.fdopen(descriptor, "rb", closefd=False) as stream: + data = stream.read(limit + 1) + if len(data) > limit: + raise IntegrationError("A configuration or integration file exceeds the size limit.") + after = os.fstat(descriptor) + if identity(before) != identity(after): + raise IntegrationError("A configuration changed while it was being read; try again.") + return Snapshot(data, stat.S_IMODE(after.st_mode), identity(after)) + finally: + os.close(descriptor) + + +def ensure_directory(path): + check_path(path) + if not path.exists(): + ensure_directory(path.parent) + try: + path.mkdir(mode=0o700) + except FileExistsError: + pass + check_path(path) + if not path.is_dir(): + raise IntegrationError("An integration parent is not a directory.") + + +def assert_unchanged(path, expected): + current = read_snapshot(path) + if current.identity != expected.identity or current.data != expected.data: + raise IntegrationError("A configuration changed during installation; try again.") + + +def atomic_write(path, data, expected, mode=None): + if len(data) > MAX_CONFIG_BYTES: + raise IntegrationError("The updated configuration would exceed the size limit.") + ensure_directory(path.parent) + descriptor, temporary = tempfile.mkstemp(prefix=".broschy-", dir=str(path.parent)) + try: + with os.fdopen(descriptor, "wb") as stream: + os.fchmod(stream.fileno(), expected.mode if mode is None else mode) + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + assert_unchanged(path, expected) + os.replace(temporary, path) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + +def safe_remove(path, expected): + assert_unchanged(path, expected) + if expected.data is not None: + path.unlink() + + +def no_duplicate_keys(pairs): + result = {} + for key, value in pairs: + if key in result: + raise IntegrationError("A JSON configuration contains duplicate keys; nothing was changed.") + result[key] = value + return result + + +def parse_object(snapshot, label): + if snapshot.data is None: + return {} + try: + value = json.loads(snapshot.data.decode("utf-8"), object_pairs_hook=no_duplicate_keys, + parse_constant=lambda value: (_ for _ in ()).throw(ValueError("nonfinite"))) + except (ValueError, UnicodeError, RecursionError): + raise IntegrationError(label + " is not valid JSON; nothing was changed.") from None + if not isinstance(value, dict): + raise IntegrationError(label + " must be a JSON object; nothing was changed.") + return value + + +def json_bytes(value): + return (json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n").encode("utf-8") + + +def configuration_path(provider, home, env): + if provider == "codex": + base = absolute_path(env.get("CODEX_HOME", str(home / ".codex")), "CODEX_HOME") + return base / "hooks.json" + if provider == "claude": + base = absolute_path(env.get("CLAUDE_CONFIG_DIR", str(home / ".claude")), "CLAUDE_CONFIG_DIR") + return base / "settings.json" + base = absolute_path(env.get("XDG_CONFIG_HOME", str(home / ".config")), "XDG_CONFIG_HOME") + return base / "opencode" / "plugins" / "broschy.js" + + +def validate_hooks(config): + hooks = config.get("hooks", {}) + if not isinstance(hooks, dict): + raise IntegrationError("The existing hooks setting must be an object; nothing was changed.") + for groups in hooks.values(): + if not isinstance(groups, list): + raise IntegrationError("An existing hook event must contain a list; nothing was changed.") + for group in groups: + if not isinstance(group, dict) or not isinstance(group.get("hooks"), list): + raise IntegrationError("An existing hook group is malformed; nothing was changed.") + if any(not isinstance(handler, dict) for handler in group["hooks"]): + raise IntegrationError("An existing hook handler is malformed; nothing was changed.") + return hooks + + +def handler_for(cli_path, provider): + return {"type": "command", "command": shlex.quote(str(cli_path)) + " agent hook --provider " + provider, "timeout": 2} + + +def locations(hooks, record): + found = [] + for index, group in enumerate(hooks.get(record["event"], [])): + # A user can add unrelated handlers to our group; retain those on removal. + if {k: v for k, v in group.items() if k != "hooks"} != {}: + continue + matches = [i for i, value in enumerate(group["hooks"]) if value == record["handler"]] + found.extend((index, item) for item in matches) + if len(found) > 1: + raise IntegrationError("An owned hook was duplicated; review it before changing this integration.") + return found + + +def remove_owned(config, manifest): + hooks = validate_hooks(config) + for record in manifest.get("owned", []): + matches = locations(hooks, record) + if matches: + group_index, item_index = matches[0] + group = hooks[record["event"]][group_index] + del group["hooks"][item_index] + if not group["hooks"]: + del hooks[record["event"]][group_index] + # Refuse to overwrite hand-edited variants of our exact commands. + owned_commands = {record["handler"]["command"] for record in manifest.get("owned", [])} + for groups in hooks.values(): + for group in groups: + for handler in group["hooks"]: + if handler.get("command") in owned_commands: + raise IntegrationError("A Broschy hook was edited; it has been preserved. Review that hook before uninstalling or reinstalling.") + for event in manifest.get("created_events", []): + if hooks.get(event) == []: + del hooks[event] + if not hooks and manifest.get("created_hooks"): + config.pop("hooks", None) + + +def manifest_for(provider, path, snapshot): + value = parse_object(snapshot, "The Broschy ownership manifest") + if not value: + if snapshot.data is not None: + raise IntegrationError("The Broschy ownership manifest is incomplete.") + return None + if (value.get("version") != 1 or value.get("provider") != provider + or value.get("target") != str(path) or not isinstance(value.get("cli_path"), str)): + raise IntegrationError("The integration manifest describes a different configuration location. Use its original configuration environment to uninstall it first.") + absolute_path(value["cli_path"], "The stored CLI path") + if provider == "opencode": + hashes = value.get("owned_hashes") + if not isinstance(hashes, list) or not hashes or len(hashes) > 32 or any(not isinstance(h, str) or len(h) != 64 for h in hashes): + raise IntegrationError("The Broschy plugin ownership manifest is malformed.") + if value.get("current_hash") not in hashes: + raise IntegrationError("The Broschy plugin ownership manifest is incomplete.") + else: + records = value.get("owned") + if not isinstance(records, list) or len(records) > 512: + raise IntegrationError("The Broschy hook ownership manifest is malformed.") + for record in records: + if (not isinstance(record, dict) or record.get("event") not in EVENTS[provider] + or not isinstance(record.get("handler"), dict) + or record["handler"].get("type") != "command" + or not isinstance(record["handler"].get("command"), str) + or record["handler"].get("timeout") != 2): + raise IntegrationError("The Broschy hook ownership manifest is malformed.") + if not isinstance(value.get("created_events"), list) or any(event not in EVENTS[provider] for event in value["created_events"]): + raise IntegrationError("The Broschy hook ownership manifest is malformed.") + return value + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def configured(provider, config_snapshot, manifest): + if manifest is None or config_snapshot.data is None: + return False + if provider == "opencode": + return digest(config_snapshot.data) == manifest["current_hash"] + hooks = validate_hooks(parse_object(config_snapshot, "The agent configuration")) + expected = handler_for(manifest["cli_path"], provider) + return all(len(locations(hooks, {"event": event, "handler": expected})) == 1 for event in EVENTS[provider]) + + +def cli_available(path): + check_path(path) + try: + info = path.stat() + except (FileNotFoundError, PermissionError): + return False + return stat.S_ISREG(info.st_mode) and bool(stat.S_IMODE(info.st_mode) & 0o111) and os.access(path, os.X_OK) + + +def find_cli(explicit, isolated): + candidates = [] + if explicit: + candidates.append(absolute_path(explicit, "--cli-path")) + else: + here = Path(__file__).resolve() + for parent in here.parents: + if parent.name == "Contents": + candidates.append(parent / "MacOS" / "broschy-cli") + break + candidates.append(here.parent.parent / "build" / "Broschy.app" / "Contents" / "MacOS" / "broschy-cli") + if not isolated: + located = shutil.which("broschy-cli") + if located: + candidates.append(absolute_path(located, "The CLI path")) + for path in candidates: + if cli_available(path): + return path + raise IntegrationError("Pass --cli-path with the absolute path to an executable broschy-cli.") + + +def plugin_bytes(template_path, cli_path): + path = absolute_path(template_path, "--template-path") if template_path else Path(__file__).resolve().parent.parent / "Integrations" / "opencode" / "broschy.js" + snapshot = read_snapshot(path, MAX_TEMPLATE_BYTES) + if snapshot.data is None: + raise IntegrationError("The bundled OpenCode template is missing; pass --template-path.") + try: + template = snapshot.data.decode("utf-8") + except UnicodeError: + raise IntegrationError("The OpenCode template is not UTF-8.") from None + marker = '"__BROSCHY_CLI_PATH__"' + if template.count(marker) != 1: + raise IntegrationError("The OpenCode template has an invalid CLI placeholder.") + return template.replace(marker, json.dumps(str(cli_path), ensure_ascii=True), 1).encode("utf-8") + + +@contextmanager +def mutation_lock(directory): + ensure_directory(directory) + path = directory / ".installer.lock" + check_path(path) + descriptor = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600) + try: + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + raise IntegrationError("The integration lock is not a regular file.") + try: + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise IntegrationError("Another integration change is in progress; try again.") from None + yield + finally: + os.close(descriptor) + + +def backup(directory, provider, snapshot, suffix): + if snapshot.data is None: + return + target = directory / "backups" / provider / (time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + "-" + uuid.uuid4().hex + suffix) + atomic_write(target, snapshot.data, Snapshot(None), mode=0o600) + + +def success_message(provider, action): + if action == "uninstall": + return "Broschy integration removed. Other hooks and settings were preserved." + if provider == "codex": + return "Hooks installed. Review and enable them with /hooks in Codex, then start a new session. Broschy does not change hook trust or permissions." + if provider == "claude": + return "Hooks installed. Start a new Claude Code session to load them." + return "Plugin installed. Restart OpenCode to load it." + + +def operate(args): + isolated = args.home is not None + home = absolute_path(args.home, "--home") if isolated else Path.home() + env = {} if isolated else os.environ + target = configuration_path(args.provider, home, env) + # Preserve the app's legacy support location across the product rename. + directory = home / "Library" / "Application Support" / "NotchFlow" / "agents" / "integrations" + manifest_path = directory / (args.provider + ".json") + check_path(directory) + config_snapshot = read_snapshot(target) + manifest_snapshot = read_snapshot(manifest_path) + manifest = manifest_for(args.provider, target, manifest_snapshot) + if args.provider != "opencode": + validate_hooks(parse_object(config_snapshot, "The agent configuration")) + installed = configured(args.provider, config_snapshot, manifest) + if args.action == "status": + message = success_message(args.provider, "install") if installed else "Broschy integration is not installed." + if manifest is not None and not installed: + message = "The integration is missing or was changed. Reinstall to repair it; modified hooks are preserved." + if installed: + recorded_cli = absolute_path(manifest["cli_path"], "The stored CLI path") + supplied_cli = absolute_path(args.cli_path, "--cli-path") if args.cli_path else recorded_cli + if supplied_cli != recorded_cli or not cli_available(recorded_cli): + installed = False + message = "Broschy moved or its executable is unavailable. Reconnect from the current app to repair this integration." + return {"provider": args.provider, "installed": installed, "message": message} + if args.action == "uninstall" and manifest is None: + return {"provider": args.provider, "installed": False, "message": "Broschy integration is already absent."} + + cli_path = find_cli(args.cli_path, isolated) if args.action == "install" else None + new_plugin = plugin_bytes(args.template_path, cli_path) if args.action == "install" and args.provider == "opencode" else None + with mutation_lock(directory): + # Refuse stale reads rather than overwriting concurrent user changes. + assert_unchanged(target, config_snapshot) + assert_unchanged(manifest_path, manifest_snapshot) + if args.provider == "opencode": + if config_snapshot.data is not None: + if manifest is None: + raise IntegrationError("An existing broschy.js is not owned by this installer; it was preserved.") + if digest(config_snapshot.data) not in manifest["owned_hashes"]: + raise IntegrationError("The installed plugin was edited; it was preserved. Review it before reinstalling or uninstalling.") + updated = new_plugin + new_manifest = copy.deepcopy(manifest) if manifest else { + "version": 1, "provider": args.provider, "target": str(target), "owned_hashes": [], + } + if args.action == "install": + current_hash = digest(new_plugin) + if current_hash not in new_manifest["owned_hashes"]: + new_manifest["owned_hashes"].append(current_hash) + if len(new_manifest["owned_hashes"]) > 32: + raise IntegrationError("Uninstall this integration before changing its executable again.") + new_manifest.update(cli_path=str(cli_path), current_hash=current_hash) + else: + original = parse_object(config_snapshot, "The agent configuration") + config = copy.deepcopy(original) + new_manifest = copy.deepcopy(manifest) if manifest else { + "version": 1, "provider": args.provider, "target": str(target), "owned": [], + "created_config": config_snapshot.data is None, "created_hooks": "hooks" not in original, + "created_events": [event for event in EVENTS[args.provider] if event not in original.get("hooks", {})], + } + if manifest: + remove_owned(config, manifest) + if args.action == "install": + hooks = config.setdefault("hooks", {}) + handler = handler_for(cli_path, args.provider) + for event in EVENTS[args.provider]: + record = {"event": event, "handler": handler} + # Do not adopt user-managed lookalikes on a first installation. + if any(item.get("command") == handler["command"] for group in hooks.get(event, []) + for item in group["hooks"]): + raise IntegrationError("A matching hook exists without Broschy ownership; it was preserved.") + hooks.setdefault(event, []).append({"hooks": [copy.deepcopy(handler)]}) + if record not in new_manifest["owned"]: + new_manifest["owned"].append(copy.deepcopy(record)) + if len(new_manifest["owned"]) > 512: + raise IntegrationError("Uninstall this integration before changing its executable again.") + new_manifest["cli_path"] = str(cli_path) + updated = None if args.action == "uninstall" and (config_snapshot.data is None or (not config and manifest.get("created_config"))) else json_bytes(config) + # Preserve the original formatting on an idempotent install. + if config == original: + updated = config_snapshot.data + if args.action == "install" and updated == config_snapshot.data and new_manifest == manifest: + return {"provider": args.provider, "installed": True, "message": success_message(args.provider, "install")} + if updated is not None and len(updated) > MAX_CONFIG_BYTES: + raise IntegrationError("The updated configuration would exceed the size limit.") + backup(directory, args.provider, config_snapshot, ".js" if args.provider == "opencode" else ".json") + if args.action == "install": + # Record both current and past owned handlers before updating config. + # An interrupted reinstall can safely remove either version later. + atomic_write(manifest_path, json_bytes(new_manifest), manifest_snapshot, mode=0o600) + if updated is None: + safe_remove(target, config_snapshot) + else: + atomic_write(target, updated, config_snapshot) + if args.action == "uninstall": + safe_remove(manifest_path, manifest_snapshot) + return {"provider": args.provider, "installed": args.action == "install", "message": success_message(args.provider, args.action)} + + +def main(argv=None): + parser = JSONArgumentParser(description=__doc__) + parser.add_argument("action", choices=("install", "status", "uninstall")) + parser.add_argument("--provider", required=True, choices=PROVIDERS) + parser.add_argument("--cli-path") + parser.add_argument("--home", help="Isolated home; ignores agent configuration environment variables.") + parser.add_argument("--template-path", help="Absolute path to the bundled OpenCode JavaScript template.") + provider = "unknown" + try: + args = parser.parse_args(argv) + provider = args.provider + result = operate(args) + code = 0 + except (IntegrationError, OSError, RecursionError) as error: + if isinstance(error, IntegrationError): + message = str(error) + elif isinstance(error, RecursionError): + message = "A configuration is nested too deeply; nothing was changed." + else: + message = "The integration could not be changed: " + (error.strerror or "file operation failed") + "." + result = {"provider": provider, "installed": False, "message": message} + code = 1 + print(json.dumps(result, ensure_ascii=False)) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test.sh b/test.sh index d15027d..0de88d9 100644 --- a/test.sh +++ b/test.sh @@ -3,7 +3,11 @@ set -euo pipefail cd "$(dirname "$0")" test_dir="$(mktemp -d "${TMPDIR:-/tmp}/broschy-tests.XXXXXX")" trap 'rm -rf "$test_dir"' EXIT -swiftc -D BROSCHY_TESTS -D FLOW_STORE_TESTS Sources/Broschy/Core.swift Sources/Broschy/Store.swift Sources/BroschyCLI/main.swift Tests/BroschyCoreTests.swift -o "$test_dir/checks" +swiftc -swift-version 5 -emit-library -emit-module -module-name AgentBridge Sources/AgentBridge/*.swift -emit-module-path "$test_dir/AgentBridge.swiftmodule" -o "$test_dir/libAgentBridge.dylib" +swiftc -D BROSCHY_TESTS -D FLOW_STORE_TESTS -I "$test_dir" -L "$test_dir" -lAgentBridge -Xlinker -rpath -Xlinker "$test_dir" Sources/Broschy/Core.swift Sources/Broschy/Store.swift Sources/BroschyCLI/*.swift Tests/BroschyCoreTests.swift -o "$test_dir/checks" "$test_dir/checks" "$test_dir" swiftc -swift-version 5 -D SPOTIFY_TESTS -parse-as-library Sources/Broschy/SpotifyController.swift Tests/SpotifyControllerTests.swift -o "$test_dir/spotify-checks" "$test_dir/spotify-checks" +swiftc -swift-version 5 -parse-as-library -I "$test_dir" -L "$test_dir" -lAgentBridge -Xlinker -rpath -Xlinker "$test_dir" Sources/BroschyCLI/AgentCommand.swift Tests/AgentBridgeTests.swift -o "$test_dir/agent-checks" +"$test_dir/agent-checks" "$test_dir" +python3 Tests/test_agent_integrations.py