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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
<key>CFBundleDevelopmentRegion</key><string>en</string>
<key>CFBundleLocalizations</key><array><string>en</string></array>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.6.0</string>
<key>CFBundleVersion</key><string>10</string>
<key>CFBundleShortVersionString</key><string>0.7.1</string>
<key>CFBundleVersion</key><string>12</string>
<key>LSMinimumSystemVersion</key><string>14.0</string>
<key>LSUIElement</key><true/>
<key>NSHighResolutionCapable</key><true/>
Expand Down
162 changes: 162 additions & 0 deletions Integrations/opencode/broschy.js
Original file line number Diff line number Diff line change
@@ -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.
}
},
};
};
5 changes: 3 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
)
28 changes: 20 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<h1 align="center">Broschy</h1>

<p align="center"><strong>A little more life around your notch.</strong><br>
Focus, music, command results, and quick notes — one glance away.</p>
Agents, focus, music, command results, and quick notes — one glance away.</p>

<p align="center">
<a href="https://github.com/SculptTechProject/Broschy/actions/workflows/ci.yml"><img src="https://github.com/SculptTechProject/Broschy/actions/workflows/ci.yml/badge.svg" alt="Build and test"></a>
Expand All @@ -14,7 +14,7 @@ Focus, music, command results, and quick notes — one glance away.</p>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-5eaaa1" alt="MIT license"></a>
</p>

<p align="center"><a href="#get-started">Get started</a> · <a href="#spotify">Spotify</a> · <a href="#terminal-signals">Terminal signals</a> · <a href="CONTRIBUTING.md">Contribute</a></p>
<p align="center"><a href="#get-started">Get started</a> · <a href="#agents">Agents</a> · <a href="#spotify">Spotify</a> · <a href="#terminal-signals">Terminal signals</a> · <a href="CONTRIBUTING.md">Contribute</a></p>

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.

Expand All @@ -28,10 +28,11 @@ Broschy is a small, native macOS companion that lives around your MacBook's notc

<p align="center"><sub>Actual app views with sample data. Glass adapts to your appearance and the content behind it.</sub></p>

## 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. |
Expand All @@ -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
Expand All @@ -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

<p align="center"><img src="docs/assets/agents-dark.png" alt="Agents with sample Claude Code, Codex, and OpenCode sessions, including a Needs you queue" width="480"><br><sub>Sample sessions in Dark appearance.</sub></p>

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.
Expand Down Expand Up @@ -103,22 +114,23 @@ 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.

## 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
```

Expand Down
Loading