Skip to content
Merged
3 changes: 3 additions & 0 deletions crates/freshell-freshagent/src/terminal_tabs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,9 @@ pub(crate) async fn spawn_terminal_pane(
stream_id,
&mode,
resume_session_id.as_deref(),
// REST ingress mints no createRequestId (reconciliation design §5.5
// precondition 2 — booked for the Phase-3 adoption change).
None,
None,
on_exit,
) {
Expand Down
61 changes: 60 additions & 1 deletion crates/freshell-protocol/src/client_messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,13 @@ pub enum ClientMessage {
FreshAgentKill(FreshAgentKill),
#[serde(rename = "freshAgent.fork")]
FreshAgentFork(FreshAgentFork),
#[serde(rename = "pane.reconcile.request")]
PaneReconcileRequest(PaneReconcileRequest),
}

/// The exact `type` discriminants of every client→server message, in the frozen
/// inventory's order. This is the T0 conformance checklist.
pub const CLIENT_MESSAGE_TYPES: [&str; 27] = [
pub const CLIENT_MESSAGE_TYPES: [&str; 28] = [
"claude.activity.list",
"client.diagnostic",
"codex.activity.list",
Expand All @@ -92,6 +94,7 @@ pub const CLIENT_MESSAGE_TYPES: [&str; 27] = [
"freshAgent.send",
"hello",
"opencode.activity.list",
"pane.reconcile.request",
"ping",
"terminal.attach",
"terminal.codex.candidate.persisted",
Expand All @@ -113,6 +116,11 @@ pub struct HelloCapabilities {
pub terminal_output_batch_v1: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ui_screenshot_v1: Option<bool>,
/// Reconciliation handshake opt-in (design §4.1). A client that sets this
/// MAY send `pane.reconcile.request` once the `ready` it receives
/// advertises the capability back (§4.2). Absent for the frozen client.
#[serde(skip_serializing_if = "Option::is_none")]
pub pane_reconcile_v1: Option<bool>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
Expand Down Expand Up @@ -327,6 +335,57 @@ pub struct UiScreenshotResult {
pub width: Option<i64>,
}

// --- pane.reconcile.request ---------------------------------------------------

/// One pane's identity claims, as presented by a reconciling client
/// (reconciliation-handshake design §4.3). Every field is a HINT to be
/// validated, never trusted. All fields are parse-tolerant: a malformed entry
/// must still deserialize so the server can answer it with an `invalid`
/// verdict (total cardinality, §8) instead of failing the whole frame.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReconcilePane {
/// OPAQUE to the server; echoed verbatim on the verdict. `""` when the
/// client omitted it (the entry is then `invalid`).
#[serde(default)]
pub pane_key: String,
/// v1: `"terminal"` only (fresh-agent extension deferred, §12).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
/// `TerminalMode` string as persisted (`"shell"`, `"claude"`, …).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
/// The pane's stable creation key — required by contract (§5.5); an entry
/// without one is `invalid`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub create_request_id: Option<String>,
/// Last known live handle.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terminal_id: Option<String>,
/// Locality hint, informational only.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_instance_id: Option<String>,
/// Optional identity claim.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_ref: Option<SessionLocator>,
/// Optional legacy single-key claim.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume_session_id: Option<String>,
/// Informational only — never trusted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaneReconcileRequest {
/// Client-minted, echoed verbatim; correlation only.
pub reconcile_id: String,
/// Flat list — no tree, no tab structure. Cap: 200 entries (an over-cap
/// request is answered with `error{RECONCILE_TOO_LARGE}`).
pub panes: Vec<ReconcilePane>,
}

// --- codingcli.* ------------------------------------------------------------

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
Expand Down
8 changes: 8 additions & 0 deletions crates/freshell-protocol/src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ pub enum ErrorCode {
RateLimited,
Unauthorized,
ProtocolMismatch,
/// Reconciliation handshake (`pane.reconcile.request`, design §4.3): the
/// request presented more than the 200-pane cap — answered with this error
/// instead of ever being silently truncated.
ReconcileTooLarge,
/// Reconciliation handshake (design §8): the verdict derivation itself
/// failed (poisoned lock, index handle error). The client keeps its state
/// and may re-send.
ReconcileUnavailable,
}

/// The three coding-agent providers (`claude | codex | opencode`).
Expand Down
80 changes: 79 additions & 1 deletion crates/freshell-protocol/src/server_messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ pub enum ServerMessage {
OpencodeActivityListResponse(OpencodeActivityListResponse),
#[serde(rename = "opencode.activity.updated")]
OpencodeActivityUpdated(OpencodeActivityUpdated),
#[serde(rename = "pane.reconcile.result")]
PaneReconcileResult(PaneReconcileResult),
#[serde(rename = "perf.logging")]
PerfLogging(PerfLogging),
#[serde(rename = "pong")]
Expand Down Expand Up @@ -124,7 +126,7 @@ pub enum ServerMessage {

/// The exact `type` discriminants of every server→client message, in the frozen
/// inventory's order. This is the T0 conformance checklist.
pub const SERVER_MESSAGE_TYPES: [&str; 52] = [
pub const SERVER_MESSAGE_TYPES: [&str; 53] = [
"claude.activity.list.response",
"claude.activity.updated",
"codex.activity.list.response",
Expand All @@ -150,6 +152,7 @@ pub const SERVER_MESSAGE_TYPES: [&str; 52] = [
"freshAgent.session.materialized",
"opencode.activity.list.response",
"opencode.activity.updated",
"pane.reconcile.result",
"perf.logging",
"pong",
"ready",
Expand Down Expand Up @@ -592,6 +595,64 @@ pub struct FreshAgentSessionMaterialized {
pub session_ref: Option<SessionLocator>,
}

// --- pane.reconcile.result ----------------------------------------------------

/// Authoritative per-pane verdict (reconciliation-handshake design §4.4).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReconcileVerdict {
Attach,
Respawn,
Fresh,
DeadSession,
Retry,
Invalid,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaneVerdict {
/// Echoed verbatim, 1:1 with request order.
pub pane_key: String,
pub verdict: ReconcileVerdict,
/// `attach` only: the live terminal to attach to.
#[serde(skip_serializing_if = "Option::is_none")]
pub terminal_id: Option<String>,
/// attach: authoritative identity; respawn: THE identity to resume with;
/// dead_session: the claimed-but-missing identity, for the error UI.
#[serde(skip_serializing_if = "Option::is_none")]
pub session_ref: Option<SessionLocator>,
/// Present iff the server overrode a differing client claim.
#[serde(skip_serializing_if = "Option::is_none")]
pub corrected: Option<bool>,
/// fresh / dead_session / retry / invalid: machine-readable code.
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
/// retry only.
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_after_ms: Option<i64>,
/// Row 2b (invariant I6): a newer duplicate generation exists for the same
/// `createRequestId`; the client stays on its live attachment and this
/// merely flags the duplicate `terminalId`.
#[serde(skip_serializing_if = "Option::is_none")]
pub duplicate: Option<String>,
}

/// Sent ONLY in response to `pane.reconcile.request` — the server never
/// volunteers it (frozen-client inertness gate 3, design §3).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaneReconcileResult {
/// Echoed from the request.
pub reconcile_id: String,
/// This server process's boot.
pub boot_id: String,
pub server_instance_id: String,
/// Cardinality invariant: `verdicts.len() == panes.len()`, matched 1:1 by
/// `paneKey` — a malformed entry gets `invalid`, never omission.
pub verdicts: Vec<PaneVerdict>,
}

// --- lifecycle / misc -------------------------------------------------------

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
Expand All @@ -604,6 +665,17 @@ pub struct Pong {
pub timestamp: String,
}

/// Server capability advertisement on `ready` (reconciliation-handshake design
/// §4.2). Populated **iff** the connection's `hello` carried the matching
/// client capability — today's frozen client never opts in, so the emitted
/// clean-boot handshake stays byte-for-byte unchanged.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadyCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub pane_reconcile_v1: Option<bool>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Ready {
Expand All @@ -612,6 +684,12 @@ pub struct Ready {
pub boot_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_instance_id: Option<String>,
/// Reconciliation-handshake advertisement (§4.2): `Some` only when the
/// client's `hello` opted in via `capabilities.paneReconcileV1`. A client
/// must not send `pane.reconcile.request` unless the `ready` it just
/// received advertised the capability.
#[serde(skip_serializing_if = "Option::is_none")]
pub capabilities: Option<ReadyCapabilities>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
Expand Down
18 changes: 9 additions & 9 deletions crates/freshell-protocol/tests/inventory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ fn client_types_match_inventory_exactly() {
let inv = inventory();
assert_eq!(
inv["clientToServer"]["count"].as_u64(),
Some(27),
"inventory declares 27 client→server types"
Some(28),
"inventory declares 28 client→server types"
);
let expected = json_type_set(&inv["clientToServer"]["types"]);
let actual: BTreeSet<String> = CLIENT_MESSAGE_TYPES.iter().map(|s| s.to_string()).collect();
assert_eq!(actual.len(), 27, "crate declares 27 client types (no dups)");
assert_eq!(actual.len(), 28, "crate declares 28 client types (no dups)");
assert_eq!(
actual, expected,
"CLIENT_MESSAGE_TYPES must equal the frozen inventory (no missing/extra)"
Expand All @@ -48,27 +48,27 @@ fn server_types_match_inventory_exactly() {
let inv = inventory();
assert_eq!(
inv["serverToClient"]["count"].as_u64(),
Some(52),
"inventory declares 52 server→client types"
Some(53),
"inventory declares 53 server→client types"
);
let expected = json_type_set(&inv["serverToClient"]["types"]);
let actual: BTreeSet<String> = SERVER_MESSAGE_TYPES.iter().map(|s| s.to_string()).collect();
assert_eq!(actual.len(), 52, "crate declares 52 server types (no dups)");
assert_eq!(actual.len(), 53, "crate declares 53 server types (no dups)");
assert_eq!(
actual, expected,
"SERVER_MESSAGE_TYPES must equal the frozen inventory (no missing/extra)"
);
}

#[test]
fn combined_surface_is_79() {
fn combined_surface_is_81() {
let all = all_message_types();
assert_eq!(all.len(), 79, "27 client + 52 server = 79 discriminants");
assert_eq!(all.len(), 81, "28 client + 53 server = 81 discriminants");
// sorted + unique
let unique: BTreeSet<&str> = all.iter().copied().collect();
assert_eq!(
unique.len(),
79,
81,
"no discriminant collides across directions"
);
}
Loading
Loading