Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
08bc0e5
docs: plan contextual managed recovery UI
danshapiro Sep 29, 2026
dbfeb89
docs: harden managed recovery UI plan
danshapiro Sep 29, 2026
71a526c
docs: address managed recovery plan review
danshapiro Sep 29, 2026
f62ed59
docs: close final managed recovery plan gaps
danshapiro Sep 29, 2026
70d1265
refactor(ui): remove managed runtime dashboard
danshapiro Sep 29, 2026
61ce411
feat(ui): show managed recovery in agent panes
danshapiro Sep 29, 2026
94b9702
fix(ui): clear managed terminal naming on start-new
danshapiro Sep 29, 2026
f449c3a
fix(ui): preserve managed view intent on close
danshapiro Sep 29, 2026
f581210
fix(ui): bound managed view close mutations
danshapiro Sep 29, 2026
2419a51
fix(ui): reconcile timed-out managed view closes
danshapiro Sep 29, 2026
2348fa6
fix(ui): repair late managed visibility outcomes
danshapiro Sep 29, 2026
48954b9
fix(ui): keep timed out view mutations observable
danshapiro Sep 29, 2026
e8a9b88
test(ui): assert fresh session lifecycle key
danshapiro Sep 29, 2026
8603172
test(runtime): cover configured OpenCode model readiness
danshapiro Oct 3, 2026
1488cb8
fix(test): certify pane-local loss recovery evidence
danshapiro Oct 3, 2026
886e3ac
fix(runtime): package MCP bundle with its build dependency
danshapiro Oct 3, 2026
5aac5a5
fix(test): expose managed browser listener to Docker workloads
danshapiro Oct 3, 2026
8413425
fix(test): measure restoration focus before user tab actions
danshapiro Oct 3, 2026
3fa6cb8
test: cover contextual managed recovery in browser panes
danshapiro Oct 3, 2026
6a6c0eb
fix(ui): fence managed view close repairs by current ownership
danshapiro Oct 3, 2026
a045a26
fix(ui): preserve cleanup warning details across polling
danshapiro Oct 3, 2026
30635a6
test: rely on behavioral managed recovery checks
danshapiro Oct 3, 2026
261d0ef
fix(ui): acknowledge visible repairs to fence delayed detach commits
danshapiro Oct 3, 2026
3a6a86d
fix(ui): match cleanup details to the visible warning
danshapiro Oct 3, 2026
1cee3b2
fix: report managed view close failures accurately
danshapiro Oct 3, 2026
a824d1a
fix: confirm managed cleanup before starting a new conversation
danshapiro Oct 3, 2026
04901d2
fix: preserve stop revision and catch launch cleanup failures
danshapiro Oct 3, 2026
d7866ac
fix(ui): load saved history during managed intervention
danshapiro Oct 3, 2026
8ec0f83
fix(ui): retain loaded history when native snapshots are unavailable
danshapiro Oct 3, 2026
839d553
fix: read durable managed native history without a runtime
danshapiro Oct 3, 2026
e59c240
refactor: clear managed runtime lint prerequisites
danshapiro Oct 3, 2026
11e0ca3
fix(recovery): preserve managed native history across cold reloads
danshapiro Oct 3, 2026
b631e5d
fix(e2e): declare native history JSON import type
danshapiro Oct 3, 2026
07983a8
fix(recovery): fence history reads across source transitions
danshapiro Oct 3, 2026
3e28c4e
fix(recovery): read older OpenCode native history schemas
danshapiro Oct 3, 2026
351e570
fix(runtime): acknowledge managed closes after verified stop
danshapiro Oct 3, 2026
6af42ea
fix(ui): show failed closes in Fresh Agent panes
danshapiro Oct 3, 2026
66ec386
docs: record authorized continuation of landing review
danshapiro Oct 3, 2026
7c7713a
fix: preserve managed recovery pane identity and actionable feedback
danshapiro Oct 3, 2026
eb1abec
fix: correlate managed Fresh launches before acknowledgment
danshapiro Oct 3, 2026
76bc2d3
fix: prioritize managed pane creation and view identity
danshapiro Oct 3, 2026
0b41e74
fix: retain Codex task-event conversation history
danshapiro Oct 3, 2026
068cc9a
fix: clear prior close warning when starting a new conversation
danshapiro Oct 3, 2026
2d5f3fa
fix: retain history for lost agent panes without a soul
danshapiro Oct 3, 2026
16a28e0
fix: retain interrupted Codex event messages without duplicate summaries
danshapiro Oct 3, 2026
60cace1
fix(codex): read saved history for untracked snapshots
danshapiro Oct 3, 2026
141782f
fix(codex): bind pending history messages to recorded turns
danshapiro Oct 3, 2026
0a81f52
fix(codex): associate resumed input with its new task
danshapiro Oct 3, 2026
a5d6bed
fix(ui): stop managed history polling during intervention
danshapiro Oct 3, 2026
1d0f826
fix(ui): keep background notice failures out of decision popups
danshapiro Oct 3, 2026
7583aad
docs(ui): keep normal agent recovery out of the mock
danshapiro Oct 3, 2026
564ead5
Hide routine managed terminal recovery events and replay progress
danshapiro Oct 3, 2026
1632524
Keep pending managed recovery summaries quiet
danshapiro Oct 3, 2026
7ed0e1e
Stream bounded retained native conversation history
danshapiro Oct 4, 2026
204b88d
Advance Codex activity tail cursors by bytes read
danshapiro Oct 4, 2026
d363c37
Retain recent native message parts and preserve literal saved markers
danshapiro Oct 4, 2026
1f8f283
test(server): retain listener ownership during rebind fixture startup
danshapiro Oct 4, 2026
cfa0f49
fix: keep managed automatic recovery quiet and preserve identity
danshapiro Oct 4, 2026
0d65822
Preserve managed fresh-agent recovery through live attachment
danshapiro Oct 4, 2026
e7585f2
fix(managed-runtime): restore fresh panes from hosted live snapshots
danshapiro Oct 4, 2026
244c500
fix(runtime): preserve full hosted fresh-agent snapshots
danshapiro Oct 4, 2026
abad3b8
fix(fresh-agent): preserve history during snapshot outages
danshapiro Oct 4, 2026
32419ef
Preserve hosted OpenCode registration and owned recovery history
danshapiro Oct 4, 2026
40b1131
Remove redundant native history path borrows
danshapiro Oct 4, 2026
5accbff
Route owned read-only history helpers through runtime test broker
danshapiro Oct 4, 2026
588498d
Prove owned host outage preserves saved conversation and native identity
danshapiro Oct 4, 2026
4d9a800
Reject unavailable OpenCode snapshot fallback as live truth
danshapiro Oct 4, 2026
f31141e
Isolate owned history browser proof before observer recovery
danshapiro Oct 4, 2026
78c67f9
Reattach owned controller before bounded host history proof
danshapiro Oct 4, 2026
e9302af
Refresh stale controller epochs for managed fresh-agent reads
danshapiro Oct 4, 2026
4e356c5
Measure owned managed history reload without changing assertions
danshapiro Oct 4, 2026
176f604
Observe owned snapshot request closures through opt-in audit bridge
danshapiro Oct 4, 2026
ab6bba9
Preserve owned conversation history during canonical bootstrap
danshapiro Oct 4, 2026
d6b3633
Preserve Fresh Agent history when Start new is refused during close
danshapiro Oct 4, 2026
7711fa8
Expect the normal close error after the browser refusal acknowledgment
danshapiro Oct 4, 2026
c3c3d92
Scope saved Codex history assertions to the conversation area
danshapiro Oct 4, 2026
b214718
fix: preserve typed snapshot ownership refusals
danshapiro Oct 4, 2026
4ee08bb
docs: record parallel validation and repair requirement
danshapiro Oct 4, 2026
6ea5970
test: remove static checkout runtime inventory guard
danshapiro Oct 4, 2026
621d255
docs: record canonical test library repair requirement
danshapiro Oct 4, 2026
84d4c41
fix(sandbox): include full workspace native libraries
danshapiro Oct 4, 2026
3f739de
test(e2e): scope saved recovery history to transcript turns
danshapiro Oct 4, 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
3 changes: 3 additions & 0 deletions Cargo.lock

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

67 changes: 67 additions & 0 deletions crates/freshell-agent-runtime/src/host_actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,15 @@ pub trait FreshAgentTransport: Send + Sync {
async fn capture(&self, _max_bytes: usize) -> Result<FreshAgentCapture, String> {
Err("provider does not expose a hosted snapshot".into())
}
async fn snapshot(&self) -> Result<Value, String> {
Err("provider does not expose a hosted snapshot".into())
}

/// A registered zero-turn identity is transport-owned, not a durable native identity.
/// Only transports that can prove their current local registration opt in.
async fn registered_snapshot_identity(&self) -> Option<String> {
None
}
/// Whether this actor still owns a usable provider enclosure. Provider
/// adapters may self-heal a child internally; they should report false
/// only when no live owned session remains.
Expand Down Expand Up @@ -871,6 +880,64 @@ impl FreshAgentHostActor {
.map_err(ActorError::Transport)
}

pub async fn snapshot(&self) -> Result<Value, ActorError> {
if !self.transport.is_live().await {
return Err(ActorError::Transport("provider is not live".into()));
}
let snapshot = self
.transport
.snapshot()
.await
.map_err(ActorError::Transport)?;
if !self.transport.is_live().await {
return Err(ActorError::Transport(
"provider exited during snapshot read".into(),
));
}
let profile = self.profile().await;
let provider = if profile.provider == freshell_runtime_protocol::FreshProvider::Kilroy {
"claude"
} else {
profile.provider.as_str()
};
let session_type = match profile.provider {
FreshProvider::Claude => "freshclaude",
FreshProvider::Codex => "freshcodex",
FreshProvider::Opencode => "freshopencode",
FreshProvider::Kilroy => "kilroy",
};
let registered =
if profile.native_session_id.is_none() && profile.provider == FreshProvider::Opencode {
self.transport.registered_snapshot_identity().await
} else {
None
};
// Recheck after the registration read: materialization must win over a placeholder.
let profile = self.profile().await;
let expected = profile
.native_session_id
.as_deref()
.or(registered.as_deref());
if expected.is_none()
|| snapshot["threadId"].as_str() != expected
|| snapshot["provider"].as_str() != Some(provider)
|| snapshot["sessionType"].as_str() != Some(session_type)
{
return Err(ActorError::NativeIdentityMismatch);
}
// Snapshots use the existing native-history reply allowance, including envelope overhead.
if serde_json::to_vec(&snapshot)
.map_err(|error| ActorError::Transport(error.to_string()))?
.len()
> freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES - 4096
{
return Err(ActorError::Transport(
"provider snapshot exceeds history reply frame limit".into(),
));
}
Ok(snapshot)
}

pub async fn record_event(&self, event: AgentEvent) -> Result<u64, ActorError> {
let mut state = self.state.lock().await;
let sequence =
Expand Down
186 changes: 186 additions & 0 deletions crates/freshell-agent-runtime/src/host_actor_tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,192 @@
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};

struct SnapshotTransport {
value: Value,
live: std::sync::atomic::AtomicBool,
exit_during_read: bool,
initial_native: Option<String>,
registered: Option<String>,
pause: Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>,
}

#[async_trait]
impl FreshAgentTransport for SnapshotTransport {
async fn start(&self, _: &FreshAgentProfile) -> Result<TransportStart, String> {
Ok(TransportStart {
native_session_id: self.initial_native.clone(),
})
}
async fn dispatch(
&self,
_: &RequestId,
_: &str,
_: &FreshAgentProfile,
) -> Result<DispatchAck, DispatchFailure> {
panic!("snapshot must not dispatch")
}
async fn resolve_permission(&self, _: &str, _: Value) -> Result<(), DispatchFailure> {
panic!("snapshot must not resolve")
}
async fn interrupt(&self) -> Result<(), String> {
panic!("snapshot must not interrupt")
}
async fn stop(self: Arc<Self>) -> Result<(), String> {
Ok(())
}
fn take_event_stream(&self) -> Option<tokio::sync::mpsc::Receiver<AgentEvent>> {
None
}
async fn is_live(&self) -> bool {
self.live.load(Ordering::SeqCst)
}
async fn registered_snapshot_identity(&self) -> Option<String> {
self.registered.clone()
}
async fn snapshot(&self) -> Result<Value, String> {
if let Some((entered, release)) = &self.pause {
entered.notify_one();
release.notified().await;
}
if self.exit_during_read {
self.live.store(false, Ordering::SeqCst);
}
Ok(self.value.clone())
}
}

#[tokio::test]
async fn snapshot_read_preserves_actor_state_and_rejects_wrong_identity_size_or_liveness() {
for scenario in [
"live",
"large",
"wrong-thread",
"wrong-provider",
"wrong-type",
"oversized",
"not-live",
"exit-during-read",
] {
let dir = tempfile::tempdir().unwrap();
let transport = Arc::new(SnapshotTransport {
value: serde_json::json!({
"threadId":if scenario == "wrong-thread" { "different-thread" } else { "snapshot-native" },
"provider":if scenario == "wrong-provider" { "codex" } else { "claude" },
"sessionType":if scenario == "wrong-type" { "freshopencode" } else { "freshclaude" }, "status":"idle",
"turns":match scenario { "oversized" => "x".repeat(freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES), "large" => "x".repeat(2 * freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES), _ => "retained".into() },
}),
live: std::sync::atomic::AtomicBool::new(scenario != "not-live"),
exit_during_read: scenario == "exit-during-read",
initial_native: Some("snapshot-native".into()),
registered: None,
pause: None,
});
let actor = FreshAgentHostActor::open(
dir.path(),
profile(FreshProvider::Claude, "snapshot-store", None),
transport,
)
.await
.unwrap();
let before = fs::read(dir.path().join("fresh-agent-state.json")).unwrap();
let result = actor.snapshot().await;
assert_eq!(
result.is_ok(),
matches!(scenario, "live" | "large"),
"{scenario}"
);
if let Ok(snapshot) = result {
assert_eq!(snapshot["threadId"], "snapshot-native");
if scenario == "large" {
assert_eq!(
snapshot["turns"].as_str().unwrap().len(),
2 * freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES
);
}
}
assert_eq!(
fs::read(dir.path().join("fresh-agent-state.json")).unwrap(),
before,
"{scenario}"
);
}
}

#[tokio::test]
async fn registered_pre_native_snapshot_requires_exact_opencode_proof_and_current_materialization()
{
for scenario in [
"exact",
"default-deny",
"foreign-placeholder",
"other-provider",
"materialized-during-read",
] {
let dir = tempfile::tempdir().unwrap();
let entered = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
let transport = Arc::new(SnapshotTransport {
value: serde_json::json!({"threadId":"freshopencode-owned", "provider":"opencode", "sessionType":"freshopencode", "turns":[]}),
live: std::sync::atomic::AtomicBool::new(true),
exit_during_read: false,
initial_native: None,
registered: if scenario == "default-deny" {
None
} else {
Some(
if scenario == "foreign-placeholder" {
"freshopencode-foreign"
} else {
"freshopencode-owned"
}
.into(),
)
},
pause: (scenario == "materialized-during-read")
.then(|| (entered.clone(), release.clone())),
});
let actor = FreshAgentHostActor::open(
dir.path(),
profile(
if scenario == "other-provider" {
FreshProvider::Claude
} else {
FreshProvider::Opencode
},
"owned",
None,
),
transport,
)
.await
.unwrap();
let before = fs::read(dir.path().join("fresh-agent-state.json")).unwrap();
let read = tokio::spawn({
let actor = actor.clone();
async move { actor.snapshot().await }
});
if scenario == "materialized-during-read" {
entered.notified().await;
actor
.observe_native_identity("ses_materialized".into())
.await
.unwrap();
release.notify_one();
}
assert_eq!(
read.await.unwrap().is_ok(),
scenario == "exact",
"{scenario}"
);
if scenario != "materialized-during-read" {
assert_eq!(
fs::read(dir.path().join("fresh-agent-state.json")).unwrap(),
before
);
}
}
}

struct OperationTransport {
operations: std::sync::Mutex<Vec<(String, FreshAgentOperation)>>,
supported: bool,
Expand Down
27 changes: 24 additions & 3 deletions crates/freshell-agent-runtime/src/snapshot_projection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,35 @@ const BOOLEAN_CAPABILITIES: &[&str] = &[
/// Rewrites every recognized snapshot in `payload`, including snapshots nested
/// in a `freshAgent.event` envelope. Non-snapshot provider events are untouched.
pub fn project_hosted_snapshot(payload: &mut Value, provider: &str, session_type: &str) {
let known_pair = matches!(
visit(
payload,
provider,
session_type,
known_pair(provider, session_type),
);
}

fn known_pair(provider: &str, session_type: &str) -> bool {
matches!(
(provider, session_type),
("claude", "freshclaude")
| ("claude", "kilroy")
| ("codex", "freshcodex")
| ("opencode", "freshopencode")
);
visit(payload, provider, session_type, known_pair);
)
}

/// The existing REST snapshot has no event `type` field.
pub fn project_hosted_rest_snapshot(snapshot: &mut Value, provider: &str, session_type: &str) {
if let Some(object) = snapshot.as_object_mut() {
let identity_matches = object.get("provider").and_then(Value::as_str) == Some(provider)
&& object.get("sessionType").and_then(Value::as_str) == Some(session_type);
project_capabilities(
object,
provider,
known_pair(provider, session_type) && identity_matches,
);
}
}

fn visit(value: &mut Value, provider: &str, session_type: &str, known_pair: bool) {
Expand Down
1 change: 1 addition & 0 deletions crates/freshell-freshagent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ async-trait = "0.1"
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = "0.10"
rusqlite = { version = "0.31", features = ["bundled", "blob"] }
# `<think>`/`<thinking>` balanced-tag segmentation for opencode assistant text
# (`itemsFromAssistantTextPart`/`normalizeBalancedThinkTags`, normalize.ts:100-189) needs a
# backreference (`<(thinking|think)...>...</\1>`) to match only same-name open/close pairs --
Expand Down
Loading
Loading