Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
83 commits
Select commit Hold shift + click to select a range
7e13772
docs(plans): add the name reliability fix plan
danshapiro Oct 22, 2026
e3936d6
feat(names): shared default-label classifier
danshapiro Oct 22, 2026
af0d645
test(names): pin every system-context lead tag in the shared fixture
danshapiro Oct 22, 2026
72e248c
fix(names): acknowledge post-migration imports without writing
danshapiro Oct 22, 2026
a2cc578
fix(names): pin the import-label bind guards and log sources as the w…
danshapiro Oct 22, 2026
b33b017
fix(names): filter provider placeholders, report automation outcomes …
danshapiro Oct 22, 2026
0712522
fix(names): never present an OpenCode placeholder as a session row title
danshapiro Oct 22, 2026
92ea526
test(names): wait for the server to know a pane before the CLI rename…
danshapiro Oct 22, 2026
bf9ac23
fix(names): never write back a superseded native baseline or queue a …
danshapiro Oct 22, 2026
581c452
fix(names): apply the first-message fallback for open sessions and sk…
danshapiro Oct 22, 2026
e19b71a
fix(names): retry failed first-message reads and log which guard refu…
danshapiro Oct 22, 2026
be3eb82
fix(names): use the configured Gemini key first and pause on credenti…
danshapiro Oct 23, 2026
7b21675
fix(names): keep a key change from being undone by an older refusal a…
danshapiro Oct 23, 2026
dca05a7
perf(names): serve name reads without the document lock
danshapiro Oct 23, 2026
bc82b48
fix(names): settle racing stamp peeks under the lock and wait out inh…
danshapiro Oct 23, 2026
7968c6b
fix(names): bind saved names on every terminal create and adoption path
danshapiro Oct 22, 2026
146e66b
fix(names): address the terminal naming bind review findings
danshapiro Oct 22, 2026
e10faa8
fix(names): capture legacy names once per device and never block layo…
danshapiro Oct 22, 2026
19593d8
fix(names): forward boot capture failures to the server log; count ev…
danshapiro Oct 22, 2026
5628805
fix(names): mirror saved names into session-owned tab titles
danshapiro Oct 22, 2026
c80ff23
fix(names): drop another session's mirrored tab title; gate the remai…
danshapiro Oct 22, 2026
bc499dc
fix(names): managed-runtime refresh no longer writes tab names
danshapiro Oct 22, 2026
0e1befe
test(names): wait for the shell prompt before the unrenamed-tab baseline
danshapiro Oct 22, 2026
2989a1d
docs(plans): record stored provider-label tab titles as residual 23
danshapiro Oct 23, 2026
7ba0bb4
fix(names): new-window rebuild restores pane titles and rename flags
danshapiro Oct 23, 2026
83f21b5
fix(names): restore a pane's rename flag only together with its title
danshapiro Oct 23, 2026
1e58cc8
test(names): queue a transaction, not a read, behind the local turn
danshapiro Oct 23, 2026
154b1d8
test(codex): pin that the incident-sized plugin/list response never l…
danshapiro Oct 23, 2026
eb29358
feat(names): log every accepted name change
danshapiro Oct 23, 2026
61f86a9
fix(names): judge a merged name change from one prior; cover the comm…
danshapiro Oct 23, 2026
faa0e0c
docs(plans): add Task 14 (heal stored provider-label tab titles; regi…
danshapiro Oct 23, 2026
af59895
fix(names): heal stored provider-label tab titles; registry opens kee…
danshapiro Oct 23, 2026
72afa6f
fix(names): the stored tab title heal keeps folder-name window titles
danshapiro Oct 23, 2026
1893921
docs(plans): Task 14 review — heal rule without the folder; Residuals…
danshapiro Oct 23, 2026
d88dfbb
docs(names): record the name-reliability rules in the rename contract
danshapiro Oct 23, 2026
09710bd
fix(names): an accepted send heals a junk name only as the first message
danshapiro Oct 23, 2026
d4e6ce2
fix(names): log an uncertain commit's name changes with their own cause
danshapiro Oct 23, 2026
109fe3a
fix(names): sweep summary and input-refusal lines describe committed …
danshapiro Oct 23, 2026
1f6c4aa
docs(names): describe the lock-free read rules as the store implement…
danshapiro Oct 23, 2026
3a70c94
fix(names): a dropped connect pauses AI naming instead of using an at…
danshapiro Oct 23, 2026
7fd73d8
fix(names): the probe after a naming pause tries another session first
danshapiro Oct 23, 2026
6d8ec3c
fix(names): a recovery create never binds through a spent naming handle
danshapiro Oct 23, 2026
f551341
fix(names): a restarted server's reconcile adoption keeps a zero-turn…
danshapiro Oct 23, 2026
675f855
docs(names): a rebuilt row rejoins its pending handle, never a spent one
danshapiro Oct 23, 2026
001da48
fix(names): a refused pane or tab rename no longer says "session rena…
danshapiro Oct 23, 2026
7ad970e
docs(names): the pane and tab rename message follows the outcome
danshapiro Oct 23, 2026
39f41f8
WIP: red tests for a tab whose agent pane leaves (client-r1-1)
danshapiro Oct 23, 2026
27b0892
WIP: mirror takes back the name when a tab leaves its session (client…
danshapiro Oct 23, 2026
04b9b64
test(names): require observed clean model input and align naming docs
danshapiro Oct 23, 2026
60c13ae
fix(names): keep first-message input across later accepted turns
danshapiro Oct 23, 2026
e3f0ba6
fix(names): preserve title ownership before saved names load
danshapiro Oct 23, 2026
e9702f1
fix(names): follow authoritative naming refs through session changes
danshapiro Oct 23, 2026
3b83c0f
test(names): cover title ownership through cold reloads and switches
danshapiro Oct 23, 2026
337761b
fix(names): publish adopted names when a local write commits
danshapiro Oct 23, 2026
182ed71
test(names): typecheck the shared browser naming journeys
danshapiro Oct 23, 2026
b8ca008
fix(names): activate stored first-message input on a resumed send
danshapiro Oct 23, 2026
a885b10
fix(names): preserve saved names through Claude undo and redo
danshapiro Oct 23, 2026
6437536
fix(names): rename the current conversation immediately after a switch
danshapiro Oct 23, 2026
4b896bb
fix(names): reject spent handles when deferred terminal binds recover
danshapiro Oct 23, 2026
52b838c
fix(names): include authoritative binding in terminal associations
danshapiro Oct 23, 2026
b06ae8b
docs(names): describe continuation and switch naming guarantees
danshapiro Oct 23, 2026
7c62a05
test(names): resume historical fixture in its recorded directory
danshapiro Oct 23, 2026
36e6654
chore(protocol): regenerate naming message schemas
danshapiro Oct 23, 2026
7f6f1c7
refactor(names): inspect continuation failures without remapping them
danshapiro Oct 23, 2026
1f143d6
fix(names): install registry copies with their complete naming layout
danshapiro Oct 23, 2026
aa9e37d
refactor(names): move terminal cache fill helpers into their module
danshapiro Oct 23, 2026
9ba62bc
fix(names): admit known live sessions on every rename surface
danshapiro Oct 23, 2026
2490b11
fix(names): retain continuation binding in the mounted fresh-agent view
danshapiro Oct 23, 2026
9467cc3
test(names): prepare resumed Claude history before continuation checks
danshapiro Oct 23, 2026
e092718
fix(naming): preserve legacy pre-bind rename compatibility
danshapiro Oct 23, 2026
391ca97
test(names): establish the explicit Freshclaude resume fixture
danshapiro Oct 23, 2026
7f068eb
test(names): read rename editor state without waiting after commit
danshapiro Oct 23, 2026
d92ecbb
test(claude): wait for durable assistant before comparing boot traces
danshapiro Oct 23, 2026
503ffdb
style(names): format rebase conflict integration
danshapiro Oct 24, 2026
4986af4
docs(names): record resumed validation allowance
danshapiro Oct 24, 2026
7422414
chore(protocol): regenerate schemas after main rebase
danshapiro Oct 24, 2026
2f8567f
style(tests): remove extra naming helper EOF line
danshapiro Oct 24, 2026
87f3913
fix(names): carry naming context through rebased REST create
danshapiro Oct 24, 2026
34a8950
fix(names): restore store read turn guards after rebase
danshapiro Oct 24, 2026
dd93261
test(names): retain optional outcomes in WebSocket fixtures
danshapiro Oct 24, 2026
1a96e45
docs: retire stale managed runtime test exception
danshapiro Oct 24, 2026
28c5d15
test(names): wait for historical Claude ownership before sending
danshapiro Oct 24, 2026
be363af
fix: wait for MCP turn idle before closing CLI trace
danshapiro Oct 25, 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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
- **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
- **One name per agent conversation** — Every Claude, Codex, and OpenCode conversation gets a single short name that appears on its pane, tab, sidebar, and history — and stays that same name across refreshes, restarts, and other devices. Freshell names new conversations automatically as you work (short names built for a tab), you can rename from anywhere, and the name is written back to the CLI's own session list where the CLI supports it.
- **One name per agent conversation** — Every Claude, Codex, and OpenCode conversation gets a single short name that appears on its pane, tab, sidebar, and history — and stays that same name across refreshes, restarts, and other devices. Freshell names new conversations automatically as you work (short names built for a tab), you can rename from anywhere, and the name is written back to the CLI's own session list where the CLI supports it. AI naming uses `GOOGLE_GENERATIVE_AI_API_KEY` at startup, or the Gemini key in Settings → Naming. Saving a nonempty Settings key replaces the running server's key; clearing the field takes effect after a restart, provided no environment key is set. With no key, naming uses the Freshell server's OneCLI connection, if it has one.
- **Progressive sidebar search** — Two-phase search with instant local results followed by deep server-side content search
- **Mobile responsive** — Auto-collapsing sidebar and overlay navigation for phones and tablets
- **Stream Deck** — Drive freshell from an Elgato Stream Deck: tabs on keys with repo icons and status backgrounds (or classic live previews and status rings), press to focus, long-press to approve or stop agents. See [Stream Deck](#stream-deck).
Expand Down Expand Up @@ -209,7 +209,7 @@ Then unplug and replug the deck. Without the rule, the connection status shows "
| `GEMINI_CMD` | No | Gemini CLI command override |
| `KIMI_CMD` | No | Kimi CLI command override |
| `AMPLIFIER_CMD` | No | Amplifier CLI command override |
| `GOOGLE_GENERATIVE_AI_API_KEY` | No | Gemini API key for AI-powered terminal summaries |
| `GOOGLE_GENERATIVE_AI_API_KEY` | No | Gemini API key for automatic conversation names and terminal summaries; takes precedence over Settings at startup |
| `FRESHELL_CLAUDE_NODE` | No | Node executable for the isolated Claude SDK sidecar (normally set by Electron) |
| `FRESHELL_CLAUDE_SIDECAR` | No | Claude sidecar entrypoint override for Rust development/service runs |
| `FRESHELL_MCP_NODE` | No | Node executable for the standalone MCP client |
Expand Down
152 changes: 152 additions & 0 deletions crates/freshell-codex/tests/remote_proxy_relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
//! (`transport.rs` is gated the same way).
#![cfg(feature = "real-transport")]

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;

use futures_util::{SinkExt, StreamExt};
Expand Down Expand Up @@ -1818,3 +1820,153 @@ async fn canary_warning_does_not_suppress_the_first_over_cap_capacity_trigger()

proxy.close().await;
}

// ── 14. the live repair-trigger loop stays fixed (name-reliability plan
// 2026-10-21, Task 12 Step 0) ────────────────────────────────────────────

/// The live log's repair-trigger flood (~1.6 lines per second per codex pane,
/// "upstream connection errored." then "client connection errored.") was one
/// loop per pane: the TUI polls `plugin/list`, whose ~17.8 MB response
/// exceeded the proxy's old 16 MiB transport default, so the upstream read
/// failed, the hub tore the connection down, and the TUI reconnected and
/// polled again. This is the size of that response.
const INCIDENT_PLUGIN_LIST_BYTES: usize = 17_800_000;

/// How long the reconnecting TUI runs. The old caps looped roughly every
/// second on loopback, so this window would have seen several cycles.
const RECONNECT_LOOP_WINDOW: Duration = Duration::from_secs(5);

/// An upstream app-server that answers EVERY request on EVERY connection with
/// the incident-sized `plugin/list` response (its sockets read at the proxy
/// caps, like a real app-server's uncapped reader). Counts accepted
/// connections.
async fn start_plugin_list_upstream() -> (String, Arc<AtomicUsize>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = listener.local_addr().unwrap();
let accepted = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&accepted);
let response = Arc::new(
json!({"id": 1, "result": {"data": huge_string(INCIDENT_PLUGIN_LIST_BYTES)}}).to_string(),
);
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
counter.fetch_add(1, Ordering::SeqCst);
let response = Arc::clone(&response);
tokio::spawn(async move {
let Ok(mut ws) = accept_async_with_config(stream, Some(proxy_ws_config())).await
else {
return;
};
while let Some(Ok(message)) = ws.next().await {
if matches!(message, Message::Text(_))
&& ws
.send(Message::Text(response.as_ref().clone()))
.await
.is_err()
{
return;
}
}
});
}
});
(format!("ws://{}:{}", addr.ip(), addr.port()), accepted)
}

/// What the reconnecting TUI saw by the end of its window.
struct TuiLoopReport {
connects: usize,
responses: usize,
/// The connection open at the deadline (closed by the caller after it has
/// drained the proxy's events, so the test's own teardown is not counted).
open: Option<
tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
>,
}

/// The incident TUI: poll `plugin/list` every 500 ms and reconnect at once
/// whenever the socket ends, until `window` elapses.
async fn run_reconnecting_plugin_list_tui(proxy_url: &str, window: Duration) -> TuiLoopReport {
let deadline = tokio::time::Instant::now() + window;
let mut report = TuiLoopReport {
connects: 0,
responses: 0,
open: None,
};
let poll = json!({"id": 1, "method": "plugin/list", "params": {}}).to_string();
while tokio::time::Instant::now() < deadline {
let mut ws = connect_tui_with_ws_config(proxy_url, proxy_ws_config()).await;
report.connects += 1;
let mut next_poll = tokio::time::Instant::now();
loop {
if tokio::time::Instant::now() >= deadline {
report.open = Some(ws);
return report;
}
if tokio::time::Instant::now() >= next_poll {
if ws.send(Message::Text(poll.clone())).await.is_err() {
break;
}
next_poll += Duration::from_millis(500);
}
let wake = next_poll.min(deadline);
match tokio::time::timeout_at(wake, ws.next()).await {
Err(_) => continue,
Ok(Some(Ok(Message::Text(text)))) => {
if text.len() >= INCIDENT_PLUGIN_LIST_BYTES {
report.responses += 1;
}
}
Ok(Some(Ok(_))) => continue,
Ok(Some(Err(_))) | Ok(None) => break,
}
}
}
report
}

#[tokio::test]
async fn incident_shaped_polling_tui_keeps_one_connection_and_logs_no_repair_trigger() {
// Production-default options (no cap injection): the 17.8 MB response
// relays, so the TUI is never disconnected, never reconnects, and the
// proxy reports nothing. With the old 16 MiB transport cap the same
// TUI reconnected every cycle (the loop the live log recorded).
let (upstream_url, accepted) = start_plugin_list_upstream().await;
let (proxy, mut events) =
CodexRemoteProxy::start(CodexRemoteProxyOptions::new(&upstream_url, true))
.await
.unwrap();

let report = run_reconnecting_plugin_list_tui(proxy.ws_url(), RECONNECT_LOOP_WINDOW).await;

let mut triggers = Vec::new();
while let Ok(event) = events.try_recv() {
if let RemoteProxyEvent::RepairTrigger(trigger) = event {
triggers.push(trigger);
}
}
assert_eq!(
(report.connects, accepted.load(Ordering::SeqCst)),
(1, 1),
"the TUI keeps one connection (and the proxy one upstream dial) for the whole window \
(repair triggers seen: {triggers:?})"
);
assert!(
report.responses >= 2,
"the TUI must actually receive the incident-sized response (got {} in {:?})",
report.responses,
RECONNECT_LOOP_WINDOW
);
assert_eq!(
triggers,
Vec::<RemoteProxyRepairTrigger>::new(),
"an incident-sized response must not produce repair triggers"
);

if let Some(mut ws) = report.open {
let _ = ws.close(None).await;
}
proxy.close().await;
}
4 changes: 2 additions & 2 deletions crates/freshell-freshagent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ tracing = "0.1"
libc = "0.2"

[dev-dependencies]
# `test-util` unlocks `#[tokio::test(start_paused = true)]`, so the shared quiet
# deadman's waiter tests drive its window on a paused clock.
# `test-util` drives the quiet deadman and naming bind's bounded retries on
# paused clocks.
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "test-util"] }
tower = { version = "0.5", features = ["util"] }
# DIAG-01 test facility: a capturing `Layer` + `tracing::subscriber::set_default`
Expand Down
1 change: 1 addition & 0 deletions crates/freshell-freshagent/src/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6532,6 +6532,7 @@ mod send_outcome;
pub use send_outcome::ClaudeSendOutcome;

mod attach;
mod continuation_name;
mod establish;
mod fork;
mod genesis;
Expand Down
41 changes: 41 additions & 0 deletions crates/freshell-freshagent/src/claude/continuation_name.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//! Undo/redo replaces a runtime while continuing the same conversation.
//! Its child inherits the current saved name once; explicit fork-to-new
//! and ordinary conversation switches never call this operation.

use super::*;
use crate::naming::NameError;
use freshell_protocol::session_names::{NamedProvider, SessionNameRef};

impl FreshClaudeState {
pub(super) async fn preserve_rollback_name(
&self,
original_session: &str,
replacement: &str,
session_type: &str,
) -> Result<(), NameError> {
if session_type == "kilroy" {
return Ok(());
}
let Some(sink) = self.naming() else {
return Ok(());
};
let durable = SessionNameRef::Session {
provider: NamedProvider::Claude,
session_id: original_session.into(),
};
// Establishment binds creates to their native UUID. A prior undo
// may have independently renamed this child, so copy this current
// durable record, never an ancestor's pending handle.
let source = durable;
let target = SessionNameRef::Session {
provider: NamedProvider::Claude,
session_id: replacement.into(),
};
sink.continue_session(source, target.clone())
.await
.inspect_err(|error| {
crate::naming::log_name_error("internal_continuation", &target, error);
})?;
Ok(())
}
}
20 changes: 20 additions & 0 deletions crates/freshell-freshagent/src/claude/rollback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,26 @@ impl FreshClaudeState {
}
};

// Preserve the name before any identity announcement can re-key a
// resumed pane. The source stays independent, including its manual
// provenance. A failed name commit leaves the original live.
if self
.preserve_rollback_name(&durable_id, &replacement, session_type)
.await
.is_err()
{
self.unwind_replacement(&replacement, &durable_id, lease_held)
.await;
self.compensate_rollback_record(&durable_id, existing.clone())
.await;
reply_sink(rollback_error_frame(
&op,
"NAME_PERSISTENCE_FAILED",
"The conversation's saved name could not be retained. Try again.",
));
return;
}

// ONE awaited ledger batch: the replacement's binding (its genesis,
// the original's settings, `supersedes` = the original, which retires
// the original's row and links it to the replacement) with the
Expand Down
1 change: 1 addition & 0 deletions crates/freshell-freshagent/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12282,6 +12282,7 @@ pub(crate) mod tests {
document_generation: 1,
changed: false,
native_sync: None,
outcome: None,
}
}
}
Expand Down
Loading
Loading