Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ca92f63
docs: add implementation plan for host-pressure-pane
danshapiro Aug 26, 2026
4ad2bf8
docs: harden host-pressure-pane plan from load-bearing validation (LB…
danshapiro Aug 26, 2026
ca6950b
docs: fix 14 blocking plan findings from Fresh Eyes round 1
danshapiro Aug 26, 2026
5d19dd6
docs: fix 14 blocking plan findings from Fresh Eyes round 2 (schema f…
danshapiro Aug 26, 2026
519e504
docs: fix 14 blocking plan findings from Fresh Eyes round 3 (final ga…
danshapiro Aug 26, 2026
d6dcefc
feat(host-stats): add hoststats.* wire protocol surface (TS+Rust+cont…
danshapiro Sep 1, 2026
cb41030
feat(host-stats): /proc+/sys reader layer with fixture-based parser t…
danshapiro Sep 1, 2026
9cad4a4
feat(host-stats): subscriber-gated two-tier collector service with si…
danshapiro Sep 1, 2026
3b51364
test(host-stats): pin event-loop histogram lifecycle (review finding)
danshapiro Sep 1, 2026
4fda4f3
feat(host-stats): ws subscribe/unsubscribe/refresh wiring with subscr…
danshapiro Sep 1, 2026
4fc2ad4
feat(host-stats): hostStatsAvailable feature flag on platform payload…
danshapiro Sep 1, 2026
292906a
feat(host-stats): client slice, status helpers, and ws folding for ho…
danshapiro Sep 1, 2026
b40b2b1
test(host-stats): e2e pane spec (RED at picker option; runs green in …
danshapiro Sep 1, 2026
d237fd9
feat(host-stats): HostStatsPane component, pane kind plumbing, gated …
danshapiro Sep 1, 2026
9ba52b5
feat(host-stats): registry, REST, and MCP surface for host-stats panes
danshapiro Sep 1, 2026
39d5959
feat(host-stats): Rust collector parity — platform readers, trait bri…
danshapiro Sep 1, 2026
f83133c
fix(host-stats): collector-owned refresh run (leader-teardown parity)…
danshapiro Sep 1, 2026
a9ff7a2
fix(host-stats): drop-guard frees refresh flight slot on run panic (N…
danshapiro Sep 1, 2026
b0c2b67
test(host-stats): promote pane spec to MATRIX_SPECS (Node + Rust legs…
danshapiro Sep 1, 2026
e766a0a
docs(host-stats): README bullet, docs/index.html picker tile, AGENTS.…
danshapiro Sep 1, 2026
c8c7bf8
fix(host-stats): satisfy strict workspace clippy (-D warnings) for th…
danshapiro Sep 1, 2026
23c1be9
test(host-stats): add Gauge to remaining exhaustive lucide mocks (ful…
danshapiro Sep 1, 2026
9d99916
fix(host-stats): strict-clippy round 2 (from_env init, StatfsInfo alias)
danshapiro Sep 1, 2026
5e50691
test(codex): close fork/exec /proc cmdline race in sidecar identity t…
danshapiro Sep 1, 2026
8bd74c5
style: cargo fmt on the host-stats delta (CI rustfmt gate)
danshapiro Sep 1, 2026
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: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ live in [docs/development/gcloud-robot.md](docs/development/gcloud-robot.md).

**Configuration Persistence:** User config stored at `~/.freshell/config.json`. Atomic writes with temp file + rename. Settings changes POST to `/api/settings` and broadcast via WebSocket.

**Pane System:** Tabs contain pane layouts (tree structure of splits). Each pane owns its terminal lifecycle via `createRequestId` and `terminalId`. When splitting panes, each new pane gets its own `createRequestId`, ensuring independent backend terminals. Pane content types: `terminal` (with mode, shell, status) and `browser` (with URL, devtools state).
**Pane System:** Tabs contain pane layouts (tree structure of splits). Each pane owns its terminal lifecycle via `createRequestId` and `terminalId`. When splitting panes, each new pane gets its own `createRequestId`, ensuring independent backend terminals. Pane content types: `terminal` (with mode, shell, status), `browser` (with URL, devtools state), `editor` (file path), and `host-stats` (host pressure dashboard; no per-pane payload).

**Agent Status Indicators:** Blue/busy status is derived from provider activity slices through `resolvePaneActivity`; green/needs-attention and the idle sound flow through `recordTurnComplete` and `useTurnCompletionNotifications`. Turn-complete (green/sound) is server-authoritative everywhere: terminal CLIs via `terminal.turn.complete`, and fresh-agent panes (freshclaude/kilroy/freshcodex/freshopencode) via a discrete `freshAgent.turn.complete` edge emitted only on a positive completion — freshclaude/kilroy on the SDK `result` with `subtype === 'success'`, freshopencode on the success-only `emitStatus(idle)` path, and freshcodex on `turn/completed` only when `params.turn.status === 'completed'` (the notification also fires on interrupt). The client folds it in via `applyFreshAgentCompletion` using the `at`-monotonic dedupe regime (wall-clock `at`, no per-session counter, so a resumed durable session can't swallow completions across a server restart). The waiting-for-approval edge is ALSO server-authoritative: the Claude/kilroy `SdkBridge` emits a discrete `freshAgent.turn.waiting` edge on the 0→≥1 pending permission/question transition (only Claude/kilroy raise approvals/questions), and the client folds it in via `applyFreshAgentWaiting` under a distinct `${provider}:${sessionId}#waiting` dedupe namespace so it can never poison (or be poisoned by) the turn-complete bucket. The fragile client-side busy→idle derivation AND the client-side waiting-edge hook (`useAgentSessionTurnCompletion`) were both removed — all green/sound edges are now server-emitted. freshcodex additionally self-heals a crashed/disconnected codex sidecar by consuming the runtime `onExit` hook in `subscribe()`, emitting `sdk.status:'exited'` to clear BLUE (no chime — a crash is not a positive completion). `freshopencode` still runs on a shared long-lived `opencode serve` sidecar and uses server-pushed `session.idle`/`session.status` events to drive busy. Gemini and Kimi terminal modes are status-in... [truncated] Separately, the sidebar shows cross-device remote status rings around a session row's icon: a green ring means the session is open on another device, a blue ring means it is busy on another device (blue wins over green), and rings are suppressed entirely when the session is open on this device (derived from `tabs.sync` registry snapshots — producing clients stamp pane payloads with `sessionKeys`/`busySessionKeys`, consumers re-query remote snapshots on a 30s interval, and the server partitions same-device records into `sameDeviceOpen`, which never produces rings).

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
- **Extension system** — Add new pane types, CLI integrations, and server-side services via manifest-based extensions. Enable and disable from the Extensions management page.
- **Self-configuring workspace** — Just ask Claude or Codex to open a browser in a pane, or create a tab with four subagents. Built-in tmux-like API and skill makes it simple.
- **Live pane headers** — See your active directory, git branch, and context usage in every pane title bar, updating live as you work. Fresh-agent panes carry their context meter in their status strip instead of the header.
- **Host pressure dashboard pane** — CPU, memory, pressure, and I/O at a glance with near-zero overhead (metrics stream only while you're watching). Linux, WSL, and macOS only — not shown on Windows.
- **Activity notifications** — Configurable attention indicators (highlight, pulse, darken) on tabs and pane headers when a coding CLI finishes its turn, with click or type dismiss modes
- **AI-powered session titles** — Right-click any session and generate a Gemini-powered title based on conversation content
- **Progressive sidebar search** — Two-phase search with instant local results followed by deep server-side content search
Expand Down
24 changes: 23 additions & 1 deletion crates/freshell-codex/src/sidecar_store_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,34 @@ fn spawn_own_sleep_child() -> ChildGuard {
}

/// A record carrying the spawned child's REAL `/proc` evidence.
///
/// Race note: between fork() and exec(), `/proc/<pid>/cmdline` transiently
/// holds the PARENT's argv (possibly a truncated prefix) — reading in that
/// window captures wrong bytes and the verify re-read a millisecond later
/// diverges (observed as a load-only `Mismatch` flake in `cargo test
/// --workspace`). Poll until cmdline demonstrably reflects the exec'ed child.
#[cfg(target_os = "linux")]
fn record_for_child(pid: u32) -> CodexSidecarRecord {
let cmdline = {
let mut attempts = 0;
loop {
if let Some(args) = proc_cmdline(pid as i32) {
if args == ["sleep", "300"] {
break args;
}
}
attempts += 1;
assert!(
attempts <= 1000,
"child cmdline never reflected exec within 1000ms"
);
std::thread::sleep(std::time::Duration::from_millis(1));
}
};
CodexSidecarRecord {
pid,
starttime: proc_starttime(pid as i32).expect("live child has a starttime"),
cmdline: proc_cmdline(pid as i32).expect("live child has a cmdline"),
cmdline,
..sample_record("codex-sidecar-88888888-8888-4888-8888-888888888888")
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/freshell-freshagent/src/layout_store_content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ pub fn derive_pane_title(content: &Value) -> String {
.filter(|name| !name.is_empty())
.unwrap_or("Extension")
.to_string(),
"host-stats" => "Host Stats".to_string(),
"terminal" => match obj.get("mode").and_then(Value::as_str) {
Some("claude") => "Claude CLI".to_string(),
Some("codex") => "Codex CLI".to_string(),
Expand Down
6 changes: 6 additions & 0 deletions crates/freshell-freshagent/src/layout_store_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,12 @@ fn derive_pane_title_full_matrix() {
);
assert_eq!(derive_pane_title(&json!({ "kind": "terminal" })), "Shell");

// host-stats -> fixed title (stateless pane; plan Task 8 arm)
assert_eq!(
derive_pane_title(&json!({ "kind": "host-stats" })),
"Host Stats"
);

// non-terminal unknown kinds and non-objects -> no title (Node: undefined)
assert_eq!(derive_pane_title(&json!({ "kind": "picker" })), "");
assert_eq!(derive_pane_title(&json!(null)), "");
Expand Down
15 changes: 14 additions & 1 deletion crates/freshell-freshagent/src/pane_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,20 @@ pub(crate) async fn split_pane(
Err(_) => return approx_json(Value::Null, "pane split requested; not applied"),
};

let new_content = if let Some(url) = body.get("browser").and_then(Value::as_str) {
let new_content = if body
.get("hostStats")
.and_then(Value::as_bool)
.unwrap_or(false)
{
// Stateless cheap content kind (router.ts `wantsHostStats` split branch).
let content = json!({ "kind": "host-stats" });
state
.content_panes
.lock()
.expect("content_panes mutex")
.insert(new_pane_id.clone(), content.clone());
content
} else if let Some(url) = body.get("browser").and_then(Value::as_str) {
let content = json!({
"kind": "browser",
"url": url,
Expand Down
18 changes: 18 additions & 0 deletions crates/freshell-freshagent/src/pane_ops_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,24 @@ async fn split_browser_pane_registers_cheap_content_no_terminal() {
assert_eq!(body["message"], json!("pane split (non-terminal)"));
}

#[tokio::test]
async fn split_host_stats_pane_registers_cheap_content_no_terminal() {
let state = state_with_registry();
let router = app(state.clone());
let (_tab_id, pane_id, _terminal_id) = create_shell_tab(router.clone()).await;

let (status, body) = post(
router,
&format!("/api/panes/{pane_id}/split"),
json!({ "hostStats": true }),
true,
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert!(body["data"]["terminalId"].is_null());
assert_eq!(body["message"], json!("pane split (non-terminal)"));
}

/// kata ejh6: `POST /api/panes/:id/split` REFUSES a body carrying the legacy
/// `resumeSessionId` field at the door-top — 400 with the frozen text,
/// presence-based for EVERY JSON value type, and (finding 3) the layout must
Expand Down
32 changes: 32 additions & 0 deletions crates/freshell-freshagent/src/terminal_tabs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,21 @@ async fn create_terminal_or_content_tab_with_delivery(
.and_then(Value::as_str)
.map(str::to_string);

// `hostStats: true` -> stateless host-stats pane (router.ts `wantsHostStats`
// branch before browser): no process, no terminal admission.
if body
.get("hostStats")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return create_content_tab(
&state,
name,
json!({ "kind": "host-stats" }),
restore_key.as_deref(),
broadcast,
);
}
if let Some(url) = body.get("browser").and_then(Value::as_str) {
// `devToolsOpen` flows into the frozen client verbatim via
// `paneContent` (ui-commands.ts `tab.create` -> initLayout), so a
Expand Down Expand Up @@ -3179,6 +3194,23 @@ mod tests {
assert!(body["data"]["tabId"].as_str().is_some());
}

#[tokio::test]
async fn create_host_stats_tab_attaches_host_stats_pane_content_and_no_terminal() {
let state = state_with_registry();
let mut rx = state.broadcast_tx.subscribe();
let (status, body) =
post(app(state), "/api/tabs", json!({ "hostStats": true }), true).await;
assert_eq!(status, StatusCode::OK);
assert!(body["data"]["tabId"].as_str().is_some());
assert!(body["data"]["paneId"].as_str().is_some());
assert!(body["data"].get("terminalId").is_none());

let frame = rx.recv().await.expect("ui.command frame broadcast");
let msg: Value = serde_json::from_str(&frame).unwrap();
assert_eq!(msg["command"], json!("tab.create"));
assert_eq!(msg["payload"]["paneContent"]["kind"], json!("host-stats"));
}

// ── GET /api/tabs ────────────────────────────────────────────────────────

#[tokio::test]
Expand Down
Loading
Loading