7.6 KiB
Agent Sessions (prototype)
Track live Codex + Claude Code agent sessions — local Mac first, other Macs on the tailnet second — and surface them in the CodexBar menu with click-to-focus of the owning terminal window.
Why in CodexBar
CodexBar already parses ~/.claude/projects JSONL (cost scanner) and ships a bundled CLI on macOS + Linux. Sessions reuse both: the local scanner feeds the menu UI, and the same scanner exposed as codexbar sessions --json is what remote Macs run over SSH. No daemon, no new app.
Data model (CodexBarCore)
public struct AgentSession: Codable, Sendable, Identifiable {
public enum Provider: String, Codable, Sendable { case codex, claude }
public enum Source: String, Codable, Sendable { case cli, desktopApp, ide, unknown }
public enum State: String, Codable, Sendable { case active, idle }
public var id: String // session UUID when resolvable, else "pid:<pid>"
public var provider: Provider
public var source: Source
public var state: State
public var pid: Int32? // nil for file-only (e.g. Codex desktop) sessions
public var cwd: String?
public var projectName: String? // last path component of cwd
public var startedAt: Date?
public var lastActivityAt: Date? // transcript mtime
public var transcriptPath: String?
public var host: String // local hostname, or remote host label
}
active = last activity ≤ 120 s ago. idle = live process (or recent file) with older activity. Constants live in one SessionScanConfig struct (activeWindow 120 s, fileOnlyWindow 30 min) so thresholds are tunable/testable.
Local scanner (CodexBarCore, no new deps)
LocalAgentSessionScanner combines two signals:
- Process scan — parse
ps -axo pid=,ppid=,lstart=,command=.- Claude: command basename
claude(skip obvious non-agent helpers). Source: path containsApplication Support/Claude/claude-code→.desktopApp, else.cli. Deduplicate the wrapper/child pair (desktop spawnsdisclaimerparent +claudechild with same argv; keep the child). - Codex: basename
codexwith noapp-serverargument →.cli(TUI orexec).codex app-servermarks the desktop app as present but is not itself a session. - cwd per pid via one batched
lsof -a -d cwd -Fn -p <pid,pid,…>call (parsep/nrecords). Failure → cwd nil, session still listed.
- Claude: command basename
- Transcript correlation
- Claude: cwd →
~/.claude/projects/<escaped-cwd>/(escape: every non-alphanumeric ASCII →-), newest*.jsonlby mtime → session id (filename UUID), lastActivityAt (mtime). Also reuseClaudeDesktopProjectsLocatorroots so desktop local-agent-mode sessions resolve. - Codex: enumerate
~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonlfor today + yesterday ($CODEX_HOMErespected). Read only the first line (session_meta:session_id,cwd,originator,source). File with mtime ≤ fileOnlyWindow and no matching live pid → file-only session, source fromoriginator(codex_exec/exec→.cli; ide-ish originators →.ide; desktop →.desktopApp). Livecodexpids match to rollouts by cwd (newest wins); unmatched live pid still listed with nil transcript. - Never read more than the first line of any JSONL; never load whole transcripts.
- Claude: cwd →
Scanner is Sendable, pure functions where possible; ps/lsof output parsing lives in dedicated parser types fed by strings so tests use fixtures.
CLI (CodexBarCLI)
codexbar sessions— table;--json—[AgentSession](stable field names above; ISO-8601 dates).codexbar sessions focus <id>— macOS only: focus the session's terminal window (see Focus). Exit 1 if id unknown, 2 if focus failed.- Follows existing
CLI*Command.swiftconventions. Works on Linux for listing (ps/proc paths guarded), focus is Darwin-only.
Remote hosts (CodexBarCore + app)
RemoteSessionFetcher:
- Host list = manual entries (settings, ssh destinations like
steipete@clawmac) ∪ automatic Tailscale discovery (no-op when tailscale is absent): runtailscale status --json(PATH, then/Applications/Tailscale.app/Contents/MacOS/Tailscale), take online peers with"OS": "macOS"|"linux", use firstDNSNamelabel as host. Local host excluded. - Fetch per host (parallel, 5 s budget):
ssh -o BatchMode=yes -o ConnectTimeout=3 <host> sh -lc 'codexbar sessions --json'with fallback to the bundled app CLI path (resolve the canonical bundled location fromScripts/package_app.shand hardcode it as fallback:… || <bundled-path> sessions --json). Host errors are non-fatal: host shown as unreachable, others still render. - Remote focus: fire-and-forget
ssh <host> sh -lc 'codexbar sessions focus <id>'. - Refresh: local scan every 30 s while the status item exists (cheap), remote every 60 s and immediately on menu open; both skipped when the feature is off. Reuse existing refresh loop plumbing rather than new timers if it fits.
Menu UI (CodexBar app)
- New menu section Agent Sessions (N) (N = total, all hosts) above the settings/footer area, built through the existing
MenuDescriptor-style seam so it's testable headless. - Local sessions first, then one group per remote host (
clawmac — 2, unreachable hosts greyed with a tooltip). Row: state dot (● active / ○ idle), provider glyph,projectName — provider · source · 12m. - Click local row →
SessionWindowFocuser. Click remote row → remote focus ssh call. - Settings: "Sessions" group — a single enable toggle (default on) plus a manual hosts text field (comma-separated); Tailscale discovery is always on while the feature is enabled. Persist in
SettingsStorelike neighboring prefs.
Focus (macOS, app + CLI shared in Core or app-adjacent target)
SessionWindowFocuser:
- pid → walk ppid chain to the nearest ancestor whose
NSRunningApplication.bundleIdentifieris a known terminal/editor host: Ghostty, iTerm2, Apple Terminal, Warp, WezTerm, kitty, Alacritty, VS Code, Cursor, Zed, Claude desktop (com.anthropic.claudefordesktop). Fallback: the app owning the pid. - Activate the app, then AX (
AXUIElementCreateApplication→AXWindows): raise the window whose title contains projectName or the cwd tail; fallback to frontmost window of that app. Requires Accessibility permission — callAXIsProcessTrustedWithOptionswith prompt on first use; degrade gracefully (activate app only) when untrusted. - File-only sessions (no pid): Claude desktop → activate Claude.app; Codex desktop → activate Codex.app; otherwise no-op with log.
tmux pane / terminal-tab precision is out of scope for the prototype.
Tests (Tests/CodexBarTests)
Fixture-driven, no live processes, no Keychain/AX:
- ps output parser: desktop
disclaimer+claudededupe, codex vscodex app-server, weird argv. - lsof
-Fnparser. - Claude cwd escaping → project dir mapping; newest-jsonl selection (temp dirs).
- Codex rollout first-line parse → AgentSession (fixture JSONL), file-only window cutoff.
- Tailscale status JSON → host list (fixture; offline/iOS peers excluded).
- Sessions JSON round-trip (CLI output schema stability).
- Menu section descriptor: counts, grouping, unreachable-host rendering.
Non-goals (prototype)
Claude.ai chat sessions; Codex cloud tasks; historical session browsing/analytics; "waiting on permission" state; tmux pane/tab focus; Bonjour/mDNS; persistent remote daemon or push transport; widget changes. No new SPM dependencies.
Proof
make check clean; make test (or focused swift test --filter covering the new tests) green; swift run CodexBarCLI sessions --json produces plausible output on this Mac.