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
19 changes: 11 additions & 8 deletions DESIGN.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,9 @@ Workbranch Companion은 Main, Usage, Settings view를 제공하는 macOS menu ba

- Main: agent runtime 상태를 칸반 보드로 표시. `내 응답 대기`는 agent가 권한 승인, 질문 답변, plan 승인, 입력을 기다리는 상태, `실행 중`은 작업 중인 상태, `턴 종료 · 검토`는 agent의 턴이 끝나 내가 검토할 차례라는 뜻이며 task 완료를 뜻하지 않습니다. task는 대표 세션의 열에 한 번만 표시됩니다. 세션이 없거나, 관측 불명(stale)이거나, 비활성 세션만 있는 task는 보드 아래 `WORKSPACES` 목록에 둡니다. 보드 위 usage 요약은 Claude Code와 Codex를 나란히 놓고 플랜 한도(Claude 5h·weekly, Codex weekly)와 reset까지 남은 시간, 오늘 token을 표시하고, 그 아래 최근 7일을 날짜별로 두 agent의 합계와 함께 표시
- Task card: 펼치지 않아도 IDE/Terminal/Finder 실행 버튼, repo별 branch와 Git 상태(`●N` 변경 파일, `↑N` ahead, `↓N` behind, 또는 `CLEAN`), 최근 요청, 상태, provider 아이콘(Claude Code, Codex, Grok Build), 관측 시각을 표시. 클릭하면 최근 commit과 세션 상세를 펼치고, 더블클릭하면 IDE로 task를 엽니다. repo 줄마다 note를 남길 수 있습니다
- Usage: agent별 상세 — 한도, 오늘의 input/output/cache token, 두 agent를 같은 축에 놓은 14일 일별 차트, 최근 7일 표. Companion은 로그인 없이 로컬 파일만 읽습니다: `~/.claude/projects`의 Claude Code transcript와 `~/.claude.json`에 캐시된 `/usage` 결과, `~/.codex/sessions`의 Codex rollout. 한도는 각 agent가 마지막으로 관측한 값이라 관측 시각을 함께 보여주고 오래되면 흐리게 표시합니다. Claude 한도는 Claude Code가 `/usage`를 갱신할 때 바뀝니다. Settings > Claude Limits > Live Claude Limits를 켜면 Claude Code 세션이 열려 있는 동안 실시간으로 바뀝니다(opt-in: `~/.claude/settings.json`의 `statusLine`을 statusline 입력의 `rate_limits`를 저장하는 relay로 바꾸고, 기존 statusline은 그대로 실행합니다. 끄면 원래대로 되돌립니다)
- Settings: login 시 자동 실행, menu bar icon 옆 표시(Claude 5h, Claude weekly, Codex weekly, 오늘 token 중 원하는 항목을 고르고 한도를 사용 % 또는 남은 %로 표시. 예: `CL 5h 42% · W 63% CO W 18% · 31.0M`. 1분마다 갱신하고 오래된 값은 `–`로 표시), interface font와 글자 크기, Claude Code 또는 Codex theme, 화면보호기, CLI/agent 연결 설정
- 업데이트: header의 업데이트 버튼으로 업데이트 패널을 엽니다. Homebrew 확인은 요청할 때만 실행하고, 업데이트 버튼 하나로 CLI를 먼저 올린 뒤 Companion을 업데이트합니다. 설치·연결·업데이트 버튼은 실행 중 spinner를 보여주고, 결과는 해당 버튼 옆에 표시합니다
- Usage: agent별 상세 — 한도, 오늘의 input/output/cache token, 두 agent를 같은 축에 놓은 14일 일별 차트, 최근 7일 표. Companion은 로그인 없이 로컬 파일만 읽습니다: `~/.claude/projects`의 Claude Code transcript와 `~/.claude.json`에 캐시된 `/usage` 결과, `~/.codex/sessions`의 Codex rollout. 한도는 각 agent가 마지막으로 관측한 값이라 관측 시각을 함께 보여주고 오래되면 흐리게 표시합니다. Claude 한도는 Claude Code가 `/usage`를 갱신할 때 바뀝니다. Settings > General > Live Claude Limits를 켜면 Claude Code 세션이 열려 있는 동안 실시간으로 바뀝니다(opt-in: `~/.claude/settings.json`의 `statusLine`을 statusline 입력의 `rate_limits`를 저장하는 relay로 바꾸고, 기존 statusline은 그대로 실행합니다. 끄면 원래대로 되돌립니다)
- Settings: General(업데이트·연결 상태, login 시 자동 실행, Live Claude Limits), Connections, Menu Bar, Screen Saver, Appearance 다섯 탭으로 나눕니다. login 시 자동 실행, menu bar icon 옆 표시(Claude 5h, Claude weekly, Codex weekly, 오늘 token 중 원하는 항목을 고르고 한도를 사용 % 또는 남은 %로 표시. 예: `CL 5h 42% · W 63% CO W 18% · 31.0M`. 1분마다 갱신하고 오래된 값은 `–`로 표시), interface font와 글자 크기, Claude Code 또는 Codex theme, 화면보호기, CLI/agent 연결 설정
- 업데이트: header의 업데이트 버튼은 Settings > General로 이동해 업데이트를 확인하고, Status 그룹에 설치·최신 버전을 표시합니다. Homebrew 확인은 요청할 때만 실행하고, 업데이트 버튼 하나로 CLI를 먼저 올린 뒤 Companion을 업데이트합니다. 설치·연결·업데이트 버튼은 실행 중 spinner를 보여주고, 결과는 해당 버튼 옆에 표시합니다
- 화면보호기(opt-in, 기본 꺼짐): Settings > Screen Saver > While agents work 를 켜면 실행 중이거나 내 응답을 기다리는 세션이 있는 동안 화면이 잠겨 있어도 Mac 이 idle 잠자기에 들어가지 않습니다. 화면이 잠기지 않은 채 정한 시간(Dashboard after, 기본 5분) 동안 아무도 Mac 을 건드리지 않으면 macOS 화면보호기 대신 모든 모니터에 아래 dashboard 를 띄웁니다. 잠금이 가능한 환경에서는 입력 시 macOS 잠금을 요청하며, 잠긴 뒤에는 macOS 암호나 Touch ID 로 돌아옵니다. 잠금이 불가능하면 잠금 표시를 숨기고 입력 시 잠금 없이 dashboard 만 닫습니다. 잠금 요청이 3회(약 6초) 모두 실패해도 잠금 없이 dashboard 만 닫습니다. 실행 중·대기 세션이 없어지면 잠자기·화면보호기·잠금은 다시 macOS 설정을 따릅니다. Show now 는 잠금 없이 dashboard 를 미리 보여 줍니다. 추가 권한을 요청하지 않습니다

<p align="center">
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,9 @@ Workbranch Companion is a macOS menu bar app with Main, Usage, and Settings view

- Main: a kanban board of agent runtime state. `내 응답 대기` (waiting on you) means the agent needs your permission, answer, plan approval, or input; `실행 중` (running) means it is working; `턴 종료 · 검토` (turn ended, review) means its turn ended and it is your turn to review, not that the task is done. A task appears once, in its lead session's column. Tasks with no session, an unknown (stale) observation, or only inactive sessions are listed under `WORKSPACES` below the board. A usage summary above the board shows Claude Code and Codex side by side: plan limits (Claude 5h and weekly, Codex weekly) with time to reset and today's tokens, then the last seven days by day with both agents' totals
- Task cards: IDE/Terminal/Finder launchers and each repo's branch and Git facts (`●N` dirty files, `↑N` ahead, `↓N` behind, or `CLEAN`) are visible without expanding, next to the latest request, state, provider icon (Claude Code, Codex, or Grok Build), and observation age. Click a card for last commits and session details; double-click opens the task in your IDE. Each repo row keeps a note
- Usage: per-agent detail — limits, today's input/output/cache tokens, a shared 14-day daily chart, and a last-7-days table. Companion reads only local files and never signs in: Claude Code transcripts in `~/.claude/projects` and its cached `/usage` result in `~/.claude.json`, and Codex rollouts in `~/.codex/sessions`. Limits are the agents' last observation, so each shows its age and dims when stale; Claude's limits refresh when Claude Code refreshes `/usage`, or live while a Claude Code session is open once Settings > Claude Limits > Live Claude Limits is on (opt-in: it points `statusLine` in `~/.claude/settings.json` at a relay that saves the status line's `rate_limits` and still runs your previous status line; turning it off restores it)
- Settings: controls launch at login, the text beside the menu bar icon (pick any of Claude 5h, Claude weekly, Codex weekly and today's tokens, shown as used or remaining percent, for example `CL 5h 42% · W 63% CO W 18% · 31.0M`; refreshed every minute, `–` when a reading is stale), the interface font and text size, the Claude Code or Codex theme, the screen saver, and CLI/agent connections
- Updates: the header's update button opens an update panel. It checks Homebrew only when you ask, and one update action upgrades the CLI before Companion. Setup and update buttons show a spinner while they run, and each result appears next to the button that ran it
- Usage: per-agent detail — limits, today's input/output/cache tokens, a shared 14-day daily chart, and a last-7-days table. Companion reads only local files and never signs in: Claude Code transcripts in `~/.claude/projects` and its cached `/usage` result in `~/.claude.json`, and Codex rollouts in `~/.codex/sessions`. Limits are the agents' last observation, so each shows its age and dims when stale; Claude's limits refresh when Claude Code refreshes `/usage`, or live while a Claude Code session is open once Settings > General > Live Claude Limits is on (opt-in: it points `statusLine` in `~/.claude/settings.json` at a relay that saves the status line's `rate_limits` and still runs your previous status line; turning it off restores it)
- Settings: five tabs — General (update and connection status, launch at login, Live Claude Limits), Connections, Menu Bar, Screen Saver and Appearance. They control launch at login, the text beside the menu bar icon (pick any of Claude 5h, Claude weekly, Codex weekly and today's tokens, shown as used or remaining percent, for example `CL 5h 42% · W 63% CO W 18% · 31.0M`; refreshed every minute, `–` when a reading is stale), the interface font and text size, the Claude Code or Codex theme, the screen saver, and CLI/agent connections
- Updates: the header's update button opens Settings > General and checks for updates; its Status group shows the installed and latest versions. It checks Homebrew only when you ask, and one update action upgrades the CLI before Companion. Setup and update buttons show a spinner while they run, and each result appears next to the button that ran it
- Screen saver (opt-in, off by default): with Settings > Screen Saver > While agents work on, the Mac stays out of idle sleep while a session is running or waiting on you, even with the screen locked. If the screen is unlocked and nobody touches the Mac for the chosen time (Dashboard after, 5 minutes by default), every display shows the dashboard below instead of the macOS screen saver. When locking is available, input requests the macOS lock screen; after it locks, your macOS password or Touch ID is required. If locking is unavailable, the lock indicator is hidden and input only closes the dashboard without locking. If lock requests fail after three attempts (about 6 seconds), the dashboard also closes without locking. Once no session is running or waiting, sleep, screen saver and lock follow macOS settings again. Show now previews the dashboard without locking. No extra permissions are requested

<p align="center">
Expand Down
9 changes: 8 additions & 1 deletion apps/agent-runtime/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,15 @@ pub struct Session {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub completed_tools: Vec<String>,
}
/// How long a hook writer waits for another writer's commit. Hooks from
/// several sessions serialize on one database, and each commit fsyncs, so on
/// a busy disk the queue outlasts a short timeout and the event is lost.
/// Snapshot readers keep the short timeout in `open_read` and retry on the
/// next poll instead.
const WRITE_BUSY_TIMEOUT: Duration = Duration::from_secs(5);

fn initialize(db: &Connection) -> Result<()> {
db.busy_timeout(Duration::from_millis(150))?;
db.busy_timeout(WRITE_BUSY_TIMEOUT)?;
let version: u32 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
if version > 1 {
return Err("Runtime database is newer; update workbranch".into());
Expand Down
30 changes: 30 additions & 0 deletions apps/agent-runtime/tests/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,36 @@ fn concurrent_sessions_do_not_lose_updates() {
);
}

#[test]
fn hook_writer_waits_out_a_slow_concurrent_commit() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("runtime/state.sqlite3");
let mut holder = store::open(&path).unwrap();
let transaction = holder
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
.unwrap();

// Another hook writes while this one holds the lock longer than a fast
// disk would, as fsync on a busy machine does.
let writer = {
let path = path.clone();
std::thread::spawn(move || {
let mut db = store::open(&path)?;
event::ingest(&mut db, "/p/t", "claude", &json!({"session_id":"s","hook_event_name":"UserPromptSubmit","prompt":"safe request"}), 100)
})
};
std::thread::sleep(std::time::Duration::from_millis(400));
transaction.commit().unwrap();

writer.join().unwrap().unwrap();
assert_eq!(
store::snapshot(&store::open(&path).unwrap(), 100)
.unwrap()
.len(),
1
);
}

#[test]
fn migration_refuses_symlinked_archive_and_preserves_runtime() {
let root = tempfile::tempdir().unwrap();
Expand Down
67 changes: 36 additions & 31 deletions apps/companion/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { isTauri } from "@tauri-apps/api/core";
import { useCallback, useEffect, useRef, useState } from "react";
import type { SetupAction } from "./application/connections";
import { needsOnboarding, type SetupAction } from "./application/connections";
import {
buildMainViewModel,
buildMenuModel,
Expand Down Expand Up @@ -35,11 +35,12 @@ import { AgentHeader } from "./ui/AgentHeader";
import { AgentTabs, type CompanionView } from "./ui/AgentTabs";
import { ConnectionsPanel } from "./ui/ConnectionsPanel";
import { ProgressButton } from "./ui/ProgressButton";
import type { SettingsCategory } from "./ui/SettingsNav";
import { SettingsView } from "./ui/SettingsView";
import { StageBoard } from "./ui/StageBoard";
import { StatusAlert } from "./ui/StatusAlert";
import { StatusSettings } from "./ui/StatusSettings";
import type { TaskActionKind } from "./ui/TaskRow";
import { UpdatePanel } from "./ui/UpdatePanel";
import { UsageSummary } from "./ui/UsageSummary";
import { UsageView } from "./ui/UsageView";

Expand Down Expand Up @@ -67,6 +68,8 @@ export function App() {
const [visibleError, setVisibleError] = useState<string>();
const [setupError, setSetupError] = useState(false);
const [currentView, setCurrentView] = useState<CompanionView>("main");
const [settingsCategory, setSettingsCategory] =
useState<SettingsCategory>("general");
const [selectedStageTask, setSelectedStageTask] = useState<
string | undefined
>(undefined);
Expand Down Expand Up @@ -95,6 +98,7 @@ export function App() {
const main = buildMainViewModel(state, sessions);
const connections = useConnections(tauriRuntimeAvailable);
const [onboardingDismissed, setOnboardingDismissed] = useState(false);
const [setupStartedOnMain, setSetupStartedOnMain] = useState(false);
const usage = useUsage(tauriRuntimeAvailable);

const showStatus = useCallback((message: string) => {
Expand Down Expand Up @@ -185,13 +189,15 @@ export function App() {
void refresh();
void connections.refresh();
});
const [updatePanelOpen, setUpdatePanelOpen] = useState(false);
const updateAvailable = updateTargets(updates.state.status).length > 0;
// The header control opens the update status on Settings › General.
const handleCheckUpdates = useCallback(() => {
if (!tauriRuntimeAvailable) {
showStatus(TAURI_RUNTIME_UNAVAILABLE);
return;
}
setUpdatePanelOpen(true);
setCurrentView("settings");
setSettingsCategory("general");
void updates.check();
}, [showStatus, tauriRuntimeAvailable, updates.check]);

Expand Down Expand Up @@ -300,7 +306,8 @@ export function App() {
}
};

const handleSetupAction = (action: SetupAction) => {
const handleSetupAction = (action: SetupAction, onMain: boolean) => {
setSetupStartedOnMain(onMain);
void connections.run(action).then((ok) => {
if (ok) void refresh();
});
Expand All @@ -311,23 +318,15 @@ export function App() {
state={connections.state}
sessions={sessions}
onRefresh={() => void connections.refresh()}
onAction={handleSetupAction}
onAction={(action) => handleSetupAction(action, onboarding)}
onboarding={onboarding}
onDismiss={() => setOnboardingDismissed(true)}
/>
);
const needsOnboarding =
connections.state.busy !== null ||
connections.state.notice !== null ||
connections.state.error !== null ||
connections.state.status === null ||
connections.state.status.cli.state !== "ready" ||
!connections.state.status.agents.some(
(a) =>
a.state === "configured" &&
((a.lastObservedAt ?? 0) > 0 ||
sessions.some((s) => s.provider === a.provider)),
);
const showOnboarding =
tauriRuntimeAvailable &&
!onboardingDismissed &&
needsOnboarding(connections.state, sessions, setupStartedOnMain);

return (
<main
Expand All @@ -340,18 +339,9 @@ export function App() {
summary={model.summary}
status={status}
onCheckUpdates={handleCheckUpdates}
updateAvailable={updateTargets(updates.state.status).length > 0}
updateAvailable={updateAvailable}
onQuit={handleQuit}
/>
{updatePanelOpen ? (
<UpdatePanel
theme={activeTheme}
state={updates.state}
onCheck={() => void updates.check()}
onApply={() => void updates.apply()}
onClose={() => setUpdatePanelOpen(false)}
/>
) : null}
<StatusAlert
message={
setupError && connections.state.status?.cli.state !== "ready"
Expand Down Expand Up @@ -396,9 +386,7 @@ export function App() {
snapshot={usage.snapshot}
/>
) : null}
{tauriRuntimeAvailable && !onboardingDismissed && needsOnboarding
? connectionPanel(true)
: null}
{showOnboarding ? connectionPanel(true) : null}
<StageBoard
activeCount={main.activeCount}
baseRows={main.baseRows}
Expand Down Expand Up @@ -426,6 +414,23 @@ export function App() {
) : null}
{currentView === "settings" ? (
<SettingsView
attention={{
general: updateAvailable,
connections:
connections.state.status !== null &&
connections.state.status.cli.state !== "ready",
}}
category={settingsCategory}
onCategoryChange={setSettingsCategory}
status={
<StatusSettings
connections={connections.state}
onApplyUpdates={() => void updates.apply()}
onCheckUpdates={() => void updates.check()}
onOpenConnections={() => setSettingsCategory("connections")}
updates={updates.state}
/>
}
claudeLimitRelay={{
status: claudeLimitRelay.status,
loading: claudeLimitRelay.loading,
Expand Down
35 changes: 35 additions & 0 deletions apps/companion/src/application/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,38 @@ export function receiptState(
if (observedAt <= 0) return "waiting";
return after !== undefined && observedAt > after ? "verified" : "historical";
}

/**
* Whether Main shows the setup panel. It stays hidden while the first check
* runs, so it never flashes on launch. A failed status check shows it with
* the retry whoever ran the check, even when an earlier ready status is
* still cached: the setup itself may have broken, as after a CLI update.
* Once setup is known, an unready CLI or no agent with an observed event
* keeps it open. An action's progress and result keep it open only when the
* action was started there; results of actions run from Settings ›
* Connections stay in Settings.
*/
export function needsOnboarding(
state: ConnectionState,
sessions: readonly { readonly provider: Provider }[],
actionStartedOnMain: boolean,
): boolean {
// A check failure clears `lastAction`; an action's failure keeps it.
if (state.error !== null && state.lastAction === null) return true;
if (
actionStartedOnMain &&
(state.busy !== null || state.notice !== null || state.error !== null)
)
return true;
const { status } = state;
if (status === null) return false;
return (
status.cli.state !== "ready" ||
!status.agents.some(
(agent) =>
agent.state === "configured" &&
((agent.lastObservedAt ?? 0) > 0 ||
sessions.some((session) => session.provider === agent.provider)),
)
);
}
10 changes: 0 additions & 10 deletions apps/companion/src/styles/agent-shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -246,13 +246,3 @@
top: 3px;
width: 6px;
}

.update-panel {
margin-bottom: 16px;
word-break: keep-all;
}

.update-panel .connections-heading .connection-actions {
flex: 0 0 auto;
flex-wrap: nowrap;
}
Loading
Loading