Files
steipete--codexbar/docs/agent-sessions-design.md
2026-07-13 12:22:33 +08:00

99 lines
7.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
```swift
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:
1. **Process scan** — parse `ps -axo pid=,ppid=,lstart=,command=`.
- Claude: command basename `claude` (skip obvious non-agent helpers). Source: path contains `Application Support/Claude/claude-code``.desktopApp`, else `.cli`. Deduplicate the wrapper/child pair (desktop spawns `disclaimer` parent + `claude` child with same argv; keep the child).
- Codex: basename `codex` with no `app-server` argument → `.cli` (TUI or `exec`). `codex app-server` marks 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 (parse `p`/`n` records). Failure → cwd nil, session still listed.
2. **Transcript correlation**
- Claude: cwd → `~/.claude/projects/<escaped-cwd>/` (escape: every non-alphanumeric ASCII → `-`), newest `*.jsonl` by mtime → session id (filename UUID), lastActivityAt (mtime). Also reuse `ClaudeDesktopProjectsLocator` roots so desktop local-agent-mode sessions resolve.
- Codex: enumerate `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` for today + yesterday (`$CODEX_HOME` respected). 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 from `originator` (`codex_exec`/`exec``.cli`; ide-ish originators → `.ide`; desktop → `.desktopApp`). Live `codex` pids 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.
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.swift` conventions. 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): run `tailscale status --json` (PATH, then `/Applications/Tailscale.app/Contents/MacOS/Tailscale`), take online peers with `"OS": "macOS"|"linux"`, use first `DNSName` label 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 from `Scripts/package_app.sh` and 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 `SettingsStore` like neighboring prefs.
## Focus (macOS, app + CLI shared in Core or app-adjacent target)
`SessionWindowFocuser`:
1. pid → walk ppid chain to the nearest ancestor whose `NSRunningApplication.bundleIdentifier` is 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.
2. 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 — call `AXIsProcessTrustedWithOptions` with prompt on first use; degrade gracefully (activate app only) when untrusted.
3. 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`+`claude` dedupe, codex vs `codex app-server`, weird argv.
- lsof `-Fn` parser.
- 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.