{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[902a4c5c] refactor\n\nSide A — unified diff (full patch):\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex 7af6527d03c483f33f3469ce6766c01a554c5fe3..d1defd28242fd2ca3b886adc91a7070bef75e653 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -56,12 +56,9 @@ pub async fn post_ui_html(\n return ui_js_warn(&e).into_response();\n }\n let tree = state.tree.read().await;\n- let empty = crate::reducer::GroupState::new();\n- let group = tree\n- .get(&parent)\n- .map(|n| &n.local_ranking)\n- .unwrap_or(&empty);\n- let panel = ranking_panel(&parent, group);\n+ let empty = crate::reducer::NodeState::default();\n+ let node = tree.get(&parent).unwrap_or(&empty);\n+ let panel = ranking_panel(&parent, node);\n JsBuilder::new()\n .morph_selector(\"#ranking-panel\", panel)\n .into_response()\n@@ -88,9 +85,9 @@ pub async fn post_ui_html(\n .into_response()\n }\n },\n- HtmlUiAction::FetchEntity { item } => {\n+ HtmlUiAction::FetchEntity { item, kind } => {\n let id = parse_item_param(&item);\n- fetch::fetch_entity_stream(state, id).into_response()\n+ fetch::fetch_entity_stream(state, id, kind).into_response()\n }\n }\n }\ndiff --git a/server/src/fetch/html.rs b/server/src/fetch/html.rs\nindex 63634508496e224c38b9ec0308b7a6086462f925..9b6c2d0964640a157bca2a6a64cb0820238d8e4f 100644\n--- a/server/src/fetch/html.rs\n+++ b/server/src/fetch/html.rs\n@@ -5,7 +5,7 @@ use maud::{html, Markup};\n use crate::{\n form_template::template_json_compact,\n path_types::ItemId,\n- reddit::is_fetchable,\n+ reddit::{is_children_fetchable, is_fetchable},\n reducer::NodeState,\n ui_action::UI_RPC_FIELD,\n };\n@@ -26,25 +26,16 @@ fn entity_panel(node: &NodeState) -> Markup {\n }\n }\n \n-/// Reddit/API import — `POST /ui` with `fetch_entity` returns an SSE stream.\n-pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {\n- if !is_fetchable(item) {\n- return html! {};\n- }\n- let label = if fetching {\n- \"Fetching…\"\n- } else if has_data {\n- \"Fetch more\"\n- } else {\n- \"Fetch from Reddit\"\n- };\n+/// One `fetch_entity` form/button targeting `kind` (\"self\" or \"children\").\n+fn fetch_button(item: &ItemId, kind: &str, label: &str, fetching: bool) -> Markup {\n let rpc = template_json_compact(&serde_json::json!({\n \"action\": \"fetch_entity\",\n \"item\": item.as_str(),\n+ \"kind\": kind,\n }))\n .expect(\"fetch_entity rpc template\");\n html! {\n- form method=\"post\" action=\"/ui\" id=\"fetch-entity-form\" class=\"fetch-entity-form\" {\n+ form method=\"post\" action=\"/ui\" class=\"fetch-entity-form\" {\n input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc);\n @if fetching {\n button type=\"submit\" class=\"btn-secondary\" disabled { (label) }\n@@ -55,6 +46,33 @@ pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Mark\n }\n }\n \n+/// Reddit/API import controls — `POST /ui` with `fetch_entity` returns an SSE\n+/// stream whose events are JS snippets to `eval`.\n+pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {\n+ let self_ok = is_fetchable(item);\n+ let children_ok = is_children_fetchable(item);\n+ if !self_ok && !children_ok {\n+ return html! {};\n+ }\n+ let self_label = if fetching {\n+ \"Fetching…\"\n+ } else if has_data {\n+ \"Refresh this\"\n+ } else {\n+ \"Fetch from Reddit\"\n+ };\n+ html! {\n+ div id=\"fetch-controls\" class=\"fetch-controls\" {\n+ @if self_ok {\n+ (fetch_button(item, \"self\", self_label, fetching))\n+ }\n+ @if children_ok {\n+ (fetch_button(item, \"children\", if fetching { \"Fetching…\" } else { \"Fetch posts\" }, fetching))\n+ }\n+ }\n+ }\n+}\n+\n /// Entity card + fetch control (target `#entity-section` for Idiomorph / SSE).\n pub fn entity_section(item: &ItemId, node: &NodeState, fetching: bool) -> Markup {\n let has_data = node.data.is_some();\ndiff --git a/server/src/fetch/mod.rs b/server/src/fetch/mod.rs\nindex 2290f9d3a0f1cbf1806c6339f82a4515c11cc3d3..0177bb161cea1b72a100b52efdfc5e710271c9eb 100644\n--- a/server/src/fetch/mod.rs\n+++ b/server/src/fetch/mod.rs\n@@ -1,4 +1,8 @@\n //! Entity import over `POST /ui` as SSE (Reddit worker in [`crate::reddit`]).\n+//!\n+//! Each SSE event's `data` is a JS snippet that the browser `eval`s — the same\n+//! Idiomorph-morph snippets the non-streaming `/ui` responses use. There is no\n+//! bespoke JSON envelope; the client just evals whatever each event carries.\n \n pub mod html;\n \n@@ -8,14 +12,15 @@ use std::time::Duration;\n use async_stream::stream;\n use axum::response::sse::{Event, KeepAlive, Sse};\n use futures_util::Stream;\n-use serde::Serialize;\n use tokio::sync::oneshot;\n \n use crate::{\n+ html::{ranking_panel, JsBuilder},\n path_types::ItemId,\n- reddit::FetchJobResult,\n+ reddit::{FetchJobResult, FetchKind},\n reducer::NodeState,\n state::AppState,\n+ ui_action::FetchTarget,\n };\n \n pub fn now_ms() -> i64 {\n@@ -25,55 +30,62 @@ pub fn now_ms() -> i64 {\n t.as_millis() as i64\n }\n \n-#[derive(Serialize)]\n-struct SseMorphPayload {\n- selector: &'static str,\n- html: String,\n+fn js_event(js: String) -> Event {\n+ Event::default().data(js)\n }\n \n-fn morph_complete_event(html: maud::Markup) -> Event {\n- let payload = SseMorphPayload {\n- selector: \"#entity-section\",\n- html: html.into_string(),\n- };\n- let data = serde_json::to_string(&payload).unwrap_or_else(|_| \"{}\".into());\n- Event::default().event(\"complete\").data(data)\n+/// JS that surfaces a transient message in the page's `#errors` region.\n+fn error_js(message: &str) -> String {\n+ JsBuilder::new()\n+ .morph_selector(\n+ \"#errors\",\n+ maud::html! { div id=\"errors\" { p class=\"muted\" { (message) } } },\n+ )\n+ .build()\n }\n \n-/// Stream `fetching` → `complete` / `error` for [`crate::ui_action::HtmlUiAction::FetchEntity`].\n+/// Stream Idiomorph-morph JS snippets for [`crate::ui_action::HtmlUiAction::FetchEntity`].\n pub fn fetch_entity_stream(\n state: AppState,\n id: ItemId,\n+ target: FetchTarget,\n ) -> Sse>> {\n- tracing::debug!(item = %id, \"fetch entity stream opened\");\n+ let kind = match target {\n+ FetchTarget::SelfEntity => FetchKind::SelfEntity,\n+ FetchTarget::Children => FetchKind::Children,\n+ };\n+ tracing::debug!(item = %id, ?kind, \"fetch entity stream opened\");\n \n let stream = stream! {\n if id.is_root() {\n- yield Ok(Event::default().event(\"error\").data(\"{\\\"message\\\":\\\"nothing to fetch for the root\\\"}\"));\n+ yield Ok(js_event(error_js(\"Nothing to fetch for the root.\")));\n return;\n }\n \n- if !crate::reddit::is_fetchable(&id) {\n- tracing::debug!(item = %id, \"fetch stream: not fetchable\");\n- yield Ok(Event::default().event(\"error\").data(\"{\\\"message\\\":\\\"this page cannot be fetched from Reddit\\\"}\"));\n+ let fetchable = match kind {\n+ FetchKind::SelfEntity => crate::reddit::is_fetchable(&id),\n+ FetchKind::Children => crate::reddit::is_children_fetchable(&id),\n+ };\n+ if !fetchable {\n+ tracing::debug!(item = %id, ?kind, \"fetch stream: not fetchable\");\n+ yield Ok(js_event(error_js(\"This page cannot be fetched from Reddit.\")));\n return;\n }\n \n- let fetching_html = {\n+ // Optimistic \"Fetching…\" morph of the entity section.\n+ let fetching_js = {\n let tree = state.tree.read().await;\n let empty = NodeState::default();\n let node = tree.get(&id).unwrap_or(&empty);\n- html::entity_section(&id, node, true).into_string()\n+ JsBuilder::new()\n+ .morph_selector(\"#entity-section\", html::entity_section(&id, node, true))\n+ .build()\n };\n- let fetching_payload = serde_json::json!({\n- \"selector\": \"#entity-section\",\n- \"html\": fetching_html,\n- });\n- yield Ok(Event::default().event(\"fetching\").data(fetching_payload.to_string()));\n+ yield Ok(js_event(fetching_js));\n \n let (tx, rx) = oneshot::channel();\n- state.reddit.request_fetch(id.clone(), true, Some(tx));\n- tracing::debug!(item = %id, \"fetch stream: queued reddit job\");\n+ state.queue_entity_fetch(id.clone(), kind, Some(tx));\n+ tracing::debug!(item = %id, ?kind, \"fetch stream: queued reddit job\");\n \n let result = match rx.await {\n Ok(r) => r,\n@@ -82,31 +94,42 @@ pub fn fetch_entity_stream(\n FetchJobResult::Failed(\"reddit worker stopped\".into())\n }\n };\n-\n tracing::debug!(item = %id, ?result, \"fetch stream: job finished\");\n \n match result {\n- FetchJobResult::Imported | FetchJobResult::NotFound => {\n+ FetchJobResult::Imported(_)\n+ | FetchJobResult::NotFound\n+ | FetchJobResult::SkippedCached\n+ | FetchJobResult::SkippedDuplicate => {\n let tree = state.tree.read().await;\n let empty = NodeState::default();\n let node = tree.get(&id).unwrap_or(&empty);\n- yield Ok(morph_complete_event(html::entity_section(&id, node, false)));\n+ let mut b = JsBuilder::new()\n+ .morph_selector(\"#entity-section\", html::entity_section(&id, node, false));\n+ if kind == FetchKind::Children {\n+ b = b.morph_selector(\"#ranking-panel\", ranking_panel(&id, node));\n+ }\n+ yield Ok(js_event(b.build()));\n }\n- FetchJobResult::SkippedCached | FetchJobResult::SkippedDuplicate => {\n+ FetchJobResult::RateLimited { reset_secs } => {\n let tree = state.tree.read().await;\n let empty = NodeState::default();\n let node = tree.get(&id).unwrap_or(&empty);\n- yield Ok(morph_complete_event(html::entity_section(&id, node, false)));\n- }\n- FetchJobResult::RateLimited { reset_secs } => {\n- yield Ok(Event::default().event(\"error\").data(\n- serde_json::json!({\"message\": format!(\"Reddit rate limit — retry in {reset_secs}s\")}).to_string(),\n- ));\n+ let js = JsBuilder::new()\n+ .morph_selector(\"#entity-section\", html::entity_section(&id, node, false))\n+ .raw(&error_js(&format!(\"Reddit rate limit — retry in {reset_secs}s.\")))\n+ .build();\n+ yield Ok(js_event(js));\n }\n FetchJobResult::Failed(msg) => {\n- yield Ok(Event::default().event(\"error\").data(\n- serde_json::json!({\"message\": msg}).to_string(),\n- ));\n+ let tree = state.tree.read().await;\n+ let empty = NodeState::default();\n+ let node = tree.get(&id).unwrap_or(&empty);\n+ let js = JsBuilder::new()\n+ .morph_selector(\"#entity-section\", html::entity_section(&id, node, false))\n+ .raw(&error_js(&format!(\"Fetch failed: {msg}\")))\n+ .build();\n+ yield Ok(js_event(js));\n }\n }\n };\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 9314a7556306ddab969b896dbf4126b542a46722..27ce9118c73ec5643e04363ab1a36cf5da6101bf 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -6,12 +6,16 @@ use axum::{\n };\n use maud::{html, Markup, DOCTYPE};\n \n+use std::collections::HashSet;\n+\n use crate::{\n fetch::html::entity_section,\n form_template::template_json_compact,\n path_types::ItemId,\n- ranking::{top_bottom, RankedItem},\n- reducer::{GroupState, NodeState},\n+ ranking::{\n+ connected_components_from_voted_pairs, ranked_items_subset, RankedItem, MAX_ITERS, TOL,\n+ },\n+ reducer::NodeState,\n state::AppState,\n ui_action::UI_RPC_FIELD,\n };\n@@ -178,12 +182,60 @@ fn display_label(id: &ItemId) -> String {\n .to_string()\n }\n \n-pub fn ranking_panel(item: &ItemId, group: &GroupState) -> Markup {\n- let total = group.idx_to_item.len();\n- let (top, bottom) = top_bottom(group, 8);\n+/// Plain (unscored) list of children that have no votes yet.\n+fn unranked_list(label: &str, items: &[ItemId]) -> Markup {\n+ html! {\n+ @if !items.is_empty() {\n+ h3 class=\"rank-heading muted small\" { (label) }\n+ ul class=\"rank-list unranked\" {\n+ @for it in items {\n+ li {\n+ a href=(item_href(it)) {\n+ strong { (display_label(it)) }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+}\n+\n+pub fn ranking_panel(item: &ItemId, node: &NodeState) -> Markup {\n+ let group = &node.local_ranking;\n+ let n = group.idx_to_item.len();\n+ let (comps, _isolates) =\n+ connected_components_from_voted_pairs(n, group.voted_pairs.iter().copied());\n+\n+ // Each connected component of voted items is its own ranking; isolated and\n+ // never-voted children fall into the \"unranked\" bucket below.\n+ let mut ranked_ids: HashSet = HashSet::new();\n+ let mut ranked_groups: Vec> = Vec::new();\n+ for comp in &comps {\n+ if comp.len() < 2 {\n+ continue;\n+ }\n+ let ranked = ranked_items_subset(group, comp, MAX_ITERS, TOL);\n+ for r in &ranked {\n+ ranked_ids.insert(r.item.clone());\n+ }\n+ ranked_groups.push(ranked);\n+ }\n+ ranked_groups.sort_by(|a, b| b.len().cmp(&a.len()));\n+\n+ let mut unranked: Vec = node\n+ .children\n+ .iter()\n+ .filter(|c| !ranked_ids.contains(*c))\n+ .cloned()\n+ .collect();\n+ unranked.sort_by(|a, b| a.as_str().cmp(b.as_str()));\n+\n+ let has_ranked = !ranked_groups.is_empty();\n+ let multi = ranked_groups.len() > 1;\n+\n html! {\n section id=\"ranking-panel\" class=\"demo-panel\" {\n- @if total == 0 {\n+ @if !has_ranked && unranked.is_empty() {\n p class=\"muted\" {\n @if item.is_root() {\n \"No votes yet — compare two items below.\"\n@@ -192,11 +244,11 @@ pub fn ranking_panel(item: &ItemId, group: &GroupState) -> Markup {\n }\n }\n } @else {\n- (rank_list(if bottom.is_empty() { \"\" } else { \"Top\" }, &top, 1))\n- @if !bottom.is_empty() {\n- p class=\"rank-gap muted small\" { \"⋯\" }\n- (rank_list(\"Bottom\", &bottom, total - bottom.len() + 1))\n+ @for (gi, ranked) in ranked_groups.iter().enumerate() {\n+ @let label = if multi { format!(\"Ranking group {}\", gi + 1) } else { \"Ranking\".to_string() };\n+ (rank_list(&label, ranked, 1))\n }\n+ (unranked_list(\"Unranked\", &unranked))\n }\n }\n }\n@@ -239,14 +291,13 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup {\n let tree = state.tree.read().await;\n let empty_node = NodeState::default();\n let node = tree.get(&item).unwrap_or(&empty_node);\n- let group = &node.local_ranking;\n \n let body = html! {\n h1 { \"sorter\" }\n (input_panel(\"\", None))\n (breadcrumb_path(&item))\n (entity_section(&item, node, false))\n- (ranking_panel(&item, group))\n+ (ranking_panel(&item, node))\n };\n layout(\"sorter2\", body, views)\n }\ndiff --git a/server/src/reddit.rs b/server/src/reddit.rs\nindex 0e6ce32720951a58456852b67fb76a85dd9f4aad..a0eb688709478ee0185b953b41a5d26cc354764d 100644\n--- a/server/src/reddit.rs\n+++ b/server/src/reddit.rs\n@@ -24,7 +24,8 @@ pub fn ensure_partial_tree(tree: &mut GlobalTree, id: &ItemId) {\n \n #[derive(Debug, Clone, PartialEq, Eq)]\n pub enum FetchJobResult {\n- Imported,\n+ /// Number of entities written (1 for self, N for children).\n+ Imported(usize),\n NotFound,\n SkippedDuplicate,\n SkippedCached,\n@@ -32,8 +33,16 @@ pub enum FetchJobResult {\n Failed(String),\n }\n \n+/// What to import for a node: the node's own entity, or its child listing.\n+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]\n+pub enum FetchKind {\n+ SelfEntity,\n+ Children,\n+}\n+\n pub struct RedditCommand {\n pub id: ItemId,\n+ pub kind: FetchKind,\n /// User-initiated fetch bypasses the in-memory \"recently fetched\" cache.\n pub force: bool,\n pub done: Option>,\n@@ -52,8 +61,12 @@ struct RedditCredentials {\n \n #[derive(Clone)]\n pub struct RedditApiConfig {\n+ /// Unauthenticated `.json` requests (public).\n pub api_base: String,\n- pub oauth_base: String,\n+ /// `POST …/api/v1/access_token` (always www.reddit.com in production).\n+ pub oauth_token_base: String,\n+ /// Bearer-authenticated API (`GET` subreddit about, etc.).\n+ pub oauth_api_base: String,\n pub user_agent: String,\n creds: Option,\n }\n@@ -85,7 +98,8 @@ impl RedditBroker {\n \n tracing::debug!(\n api_base = %config.api_base,\n- oauth_base = %config.oauth_base,\n+ oauth_token_base = %config.oauth_token_base,\n+ oauth_api_base = %config.oauth_api_base,\n oauth = config.creds.is_some(),\n \"reddit worker started\"\n );\n@@ -99,11 +113,12 @@ impl RedditBroker {\n pub fn request_fetch(\n &self,\n id: ItemId,\n+ kind: FetchKind,\n force: bool,\n done: Option>,\n ) {\n- match self.tx.try_send(RedditCommand { id: id.clone(), force, done }) {\n- Ok(()) => tracing::debug!(item = %id, force, \"reddit fetch queued\"),\n+ match self.tx.try_send(RedditCommand { id: id.clone(), kind, force, done }) {\n+ Ok(()) => tracing::debug!(item = %id, ?kind, force, \"reddit fetch queued\"),\n Err(_) => tracing::warn!(item = %id, \"reddit fetch queue full, dropped\"),\n }\n }\n@@ -113,7 +128,8 @@ impl RedditApiConfig {\n pub fn from_env() -> Self {\n Self {\n api_base: reddit_api_base(),\n- oauth_base: reddit_oauth_base(),\n+ oauth_token_base: reddit_oauth_token_base(),\n+ oauth_api_base: reddit_oauth_api_base(),\n user_agent: default_user_agent(),\n creds: RedditCredentials::from_env(),\n }\n@@ -142,8 +158,16 @@ pub fn reddit_api_base() -> String {\n std::env::var(\"REDDIT_API_BASE\").unwrap_or_else(|_| \"https://www.reddit.com\".into())\n }\n \n-pub fn reddit_oauth_base() -> String {\n- std::env::var(\"REDDIT_OAUTH_BASE\").unwrap_or_else(|_| \"https://www.reddit.com\".into())\n+/// Where to `POST /api/v1/access_token` (not the bearer API host).\n+pub fn reddit_oauth_token_base() -> String {\n+ std::env::var(\"REDDIT_OAUTH_TOKEN_BASE\")\n+ .or_else(|_| std::env::var(\"REDDIT_OAUTH_BASE\"))\n+ .unwrap_or_else(|_| \"https://www.reddit.com\".into())\n+}\n+\n+/// Host for OAuth-authenticated API `GET`s (must not be www.reddit.com).\n+pub fn reddit_oauth_api_base() -> String {\n+ std::env::var(\"REDDIT_OAUTH_API_BASE\").unwrap_or_else(|_| \"https://oauth.reddit.com\".into())\n }\n \n pub fn default_user_agent() -> String {\n@@ -152,10 +176,16 @@ pub fn default_user_agent() -> String {\n })\n }\n \n+/// Can the node's own entity be imported (subreddit `about` or a post)?\n pub fn is_fetchable(id: &ItemId) -> bool {\n !map_item_to_reddit_api(id, \"https://example.com\").is_empty()\n }\n \n+/// Can we import this node's children (currently: a subreddit's posts)?\n+pub fn is_children_fetchable(id: &ItemId) -> bool {\n+ !map_children_url(id, \"https://example.com\").is_empty()\n+}\n+\n pub fn entity_view_from_payload(id: &ItemId, payload: &Value) -> Option {\n if id.as_str().starts_with(\"reddit.com\") {\n return parse_reddit_view(id, payload);\n@@ -181,92 +211,116 @@ async fn reddit_worker(\n client: Client,\n config: RedditApiConfig,\n ) {\n- let mut in_flight = HashSet::new();\n- let mut recently_fetched: HashMap = HashMap::new();\n+ let mut in_flight: HashSet<(ItemId, FetchKind)> = HashSet::new();\n+ let mut recently_fetched: HashMap<(ItemId, FetchKind), Instant> = HashMap::new();\n let mut current_delay = Duration::from_secs(1);\n let mut oauth: Option = None;\n let cache_ttl = Duration::from_secs(300);\n let creds = config.creds.clone();\n let api_base = config.api_base.clone();\n- let oauth_base = config.oauth_base.clone();\n+ let oauth_token_base = config.oauth_token_base.clone();\n+ let oauth_api_base = config.oauth_api_base.clone();\n \n while let Some(cmd) = rx.recv().await {\n let now = Instant::now();\n recently_fetched.retain(|_, t| now.duration_since(*t) < cache_ttl);\n \n- if in_flight.contains(&cmd.id) {\n- tracing::debug!(item = %cmd.id, \"reddit fetch skipped: already in flight\");\n+ let kind = cmd.kind;\n+ let key = (cmd.id.clone(), kind);\n+\n+ if in_flight.contains(&key) {\n+ tracing::debug!(item = %cmd.id, ?kind, \"reddit fetch skipped: already in flight\");\n notify(cmd.done, FetchJobResult::SkippedDuplicate);\n continue;\n }\n- if !cmd.force && recently_fetched.contains_key(&cmd.id) {\n- tracing::debug!(item = %cmd.id, \"reddit fetch skipped: recently fetched cache\");\n+ if !cmd.force && recently_fetched.contains_key(&key) {\n+ tracing::debug!(item = %cmd.id, ?kind, \"reddit fetch skipped: recently fetched cache\");\n notify(cmd.done, FetchJobResult::SkippedCached);\n continue;\n }\n \n- in_flight.insert(cmd.id.clone());\n+ in_flight.insert(key.clone());\n let fetch_id = cmd.id.clone();\n let done = cmd.done;\n \n tracing::debug!(\n item = %fetch_id,\n+ ?kind,\n delay_ms = current_delay.as_millis(),\n \"reddit fetch starting after delay\"\n );\n tokio::time::sleep(current_delay).await;\n \n if let Some(c) = &creds {\n- oauth = ensure_oauth_token(&client, &oauth_base, c, oauth.take()).await;\n+ oauth = ensure_oauth_token(&client, &oauth_token_base, c, oauth.take()).await;\n }\n \n let token = oauth.as_ref().map(|t| t.access_token.as_str());\n- if token.is_some() {\n- tracing::debug!(item = %fetch_id, \"reddit fetch using OAuth bearer\");\n- }\n-\n let fetch_base = if token.is_some() {\n- &oauth_base\n+ tracing::debug!(\n+ item = %fetch_id,\n+ base = %oauth_api_base,\n+ \"reddit fetch using OAuth bearer\"\n+ );\n+ &oauth_api_base\n } else {\n &api_base\n };\n- let outcome = do_fetch(&client, fetch_base, &fetch_id, token).await;\n+ let url = match kind {\n+ FetchKind::SelfEntity => map_item_to_reddit_api(&fetch_id, fetch_base),\n+ FetchKind::Children => map_children_url(&fetch_id, fetch_base),\n+ };\n+ let outcome = do_fetch(&client, &url, &fetch_id, token).await;\n \n match outcome {\n Ok(FetchOutcome::Payload(payload)) => {\n- let ts = now_ms();\n- let event = Event::EntityImported {\n- id: fetch_id.as_str().to_string(),\n- ts,\n- payload: payload.clone(),\n+ let imports: Vec<(ItemId, Value)> = match kind {\n+ FetchKind::SelfEntity => vec![(fetch_id.clone(), payload)],\n+ FetchKind::Children => parse_children(&fetch_id, &payload),\n };\n tracing::debug!(\n item = %fetch_id,\n- ts,\n- payload_keys = ?payload.as_object().map(|o| o.len()),\n- \"reddit fetch got JSON payload, appending event\"\n+ ?kind,\n+ count = imports.len(),\n+ \"reddit fetch got payload, importing\"\n );\n- match event_log.append(&event).await {\n- Err(e) => {\n- tracing::warn!(\n- item = %fetch_id,\n- err = %e,\n- \"reddit event log append failed\"\n- );\n- notify(done, FetchJobResult::Failed(e.to_string()));\n+\n+ let mut write_err: Option = None;\n+ let mut written = 0usize;\n+ for (child_id, child_payload) in imports {\n+ let event = Event::EntityImported {\n+ id: child_id.as_str().to_string(),\n+ ts: now_ms(),\n+ payload: child_payload.clone(),\n+ };\n+ if let Err(e) = event_log.append(&event).await {\n+ tracing::warn!(item = %child_id, err = %e, \"reddit event log append failed\");\n+ write_err = Some(e.to_string());\n+ break;\n+ }\n+ {\n+ let mut tree = tree.write().await;\n+ apply_entity_import(&mut tree, &child_id, child_payload);\n+ if kind == FetchKind::Children {\n+ tree.link_child(&fetch_id, &child_id);\n+ }\n }\n- Ok(()) => {\n- apply_entity_import(&mut *tree.write().await, &fetch_id, payload);\n- recently_fetched.insert(fetch_id.clone(), Instant::now());\n+ written += 1;\n+ }\n+\n+ match write_err {\n+ Some(e) => notify(done, FetchJobResult::Failed(e)),\n+ None => {\n+ recently_fetched.insert(key.clone(), Instant::now());\n current_delay = Duration::from_millis(600);\n- tracing::info!(item = %fetch_id, \"reddit entity imported\");\n- notify(done, FetchJobResult::Imported);\n+ tracing::info!(item = %fetch_id, ?kind, written, \"reddit import complete\");\n+ notify(done, FetchJobResult::Imported(written));\n }\n }\n }\n Ok(FetchOutcome::NotFound) => {\n tracing::debug!(item = %fetch_id, \"reddit fetch: not found (no event written)\");\n- recently_fetched.insert(fetch_id.clone(), Instant::now());\n+ recently_fetched.insert(key.clone(), Instant::now());\n notify(done, FetchJobResult::NotFound);\n }\n Ok(FetchOutcome::RateLimited { reset_secs }) => {\n@@ -286,7 +340,7 @@ async fn reddit_worker(\n }\n }\n \n- in_flight.remove(&fetch_id);\n+ in_flight.remove(&key);\n }\n }\n \n@@ -358,11 +412,10 @@ async fn ensure_oauth_token(\n \n async fn do_fetch(\n client: &Client,\n- api_base: &str,\n+ url: &str,\n id: &ItemId,\n bearer: Option<&str>,\n ) -> Result {\n- let url = map_item_to_reddit_api(id, api_base);\n if url.is_empty() {\n tracing::debug!(item = %id, \"reddit do_fetch: no API URL for item\");\n return Ok(FetchOutcome::NotFound);\n@@ -370,7 +423,7 @@ async fn do_fetch(\n \n tracing::debug!(item = %id, %url, bearer = bearer.is_some(), \"reddit HTTP GET\");\n \n- let mut req = client.get(&url);\n+ let mut req = client.get(url);\n if let Some(token) = bearer {\n req = req.bearer_auth(token);\n }\n@@ -408,6 +461,9 @@ async fn do_fetch(\n body_prefix = %body.chars().take(240).collect::(),\n \"reddit non-success body\"\n );\n+ if status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED {\n+ return Err(format!(\"Reddit API {status}: {body}\"));\n+ }\n return Ok(FetchOutcome::NotFound);\n }\n \n@@ -474,6 +530,43 @@ pub fn map_item_to_reddit_api(id: &ItemId, api_base: &str) -> String {\n String::new()\n }\n \n+/// Listing URL for a node's children. Currently only subreddits\n+/// (`reddit.com/r/` → `/r/.json`) expose a child listing.\n+pub fn map_children_url(id: &ItemId, api_base: &str) -> String {\n+ let path = id.as_str();\n+ if !path.starts_with(\"reddit.com/\") {\n+ return String::new();\n+ }\n+ let base = api_base.trim_end_matches('/');\n+ let segments: Vec<&str> = path.split('/').collect();\n+ if segments.len() == 3 && segments[1] == \"r\" {\n+ return format!(\"{base}/r/{}.json?raw_json=1&limit=25\", segments[2]);\n+ }\n+ String::new()\n+}\n+\n+/// Parse a subreddit listing payload into `(child_id, child_payload)` entries.\n+/// Each child id is the post's permalink under `reddit.com/…`, and the payload\n+/// is the raw `{kind, data}` listing element (persisted per child).\n+fn parse_children(_parent: &ItemId, payload: &Value) -> Vec<(ItemId, Value)> {\n+ let mut out = Vec::new();\n+ let children = match payload.pointer(\"/data/children\").and_then(|c| c.as_array()) {\n+ Some(c) => c,\n+ None => return out,\n+ };\n+ for child in children {\n+ let permalink = match child.pointer(\"/data/permalink\").and_then(|p| p.as_str()) {\n+ Some(p) if !p.is_empty() => p,\n+ _ => continue,\n+ };\n+ let path = format!(\"reddit.com{}\", permalink.trim_end_matches('/'));\n+ if let Some(id) = ItemId::parse(&path) {\n+ out.push((id, child.clone()));\n+ }\n+ }\n+ out\n+}\n+\n fn parse_reddit_view(id: &ItemId, v: &Value) -> Option {\n let segments: Vec<&str> = id.as_str().split('/').collect();\n \n@@ -512,8 +605,13 @@ fn parse_subreddit_about(v: &Value) -> Option {\n }\n \n fn parse_post_listing(v: &Value) -> Option {\n- let listing = v.as_array()?.first()?;\n- let child = listing.pointer(\"/data/children/0/data\")?;\n+ // Two shapes: a comments-page array `[listing, comments]`, or a single\n+ // listing element `{kind, data}` (from a subreddit children import).\n+ let child = if let Some(arr) = v.as_array() {\n+ arr.first()?.pointer(\"/data/children/0/data\")?\n+ } else {\n+ v.get(\"data\")?\n+ };\n let title = child.get(\"title\")?.as_str()?.to_string();\n let author = child\n .get(\"author\")\n@@ -550,6 +648,10 @@ mod tests {\n map_item_to_reddit_api(&id, \"https://www.reddit.com\"),\n \"https://www.reddit.com/r/rust/about.json?raw_json=1\"\n );\n+ assert_eq!(\n+ map_item_to_reddit_api(&id, \"https://oauth.reddit.com\"),\n+ \"https://oauth.reddit.com/r/rust/about.json?raw_json=1\"\n+ );\n }\n \n #[test]\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex 60db81a562e3590775012573225a76ba82a14563..a36cd5c9287d61536f9f4a3f6b0df2342388857a 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -208,6 +208,17 @@ impl GlobalTree {\n node.data = view;\n }\n }\n+\n+ /// Directly attach `child` under `parent`, bypassing path-based nesting.\n+ /// Used for imported listings (e.g. a subreddit's posts) so they show up\n+ /// as children of the subreddit rather than a deep `…/comments/` path.\n+ pub fn link_child(&mut self, parent: &ItemId, child: &ItemId) {\n+ self.ensure_path(parent);\n+ self.ensure_path(child);\n+ if let Some(p) = self.nodes.get_mut(parent) {\n+ p.children.insert(child.clone());\n+ }\n+ }\n }\n \n #[cfg(test)]\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex e7ff9f663e45b5e168d6a3869948e3bd890966d4..d238701208a1c708b94a778a6a2e1891a678ecbc 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -144,8 +144,13 @@ impl AppState {\n }\n \n /// User-initiated Reddit/API import (SSE / fetch module only).\n- pub fn queue_entity_fetch(&self, id: ItemId, done: Option>) {\n- self.reddit.request_fetch(id, true, done);\n+ pub fn queue_entity_fetch(\n+ &self,\n+ id: ItemId,\n+ kind: crate::reddit::FetchKind,\n+ done: Option>,\n+ ) {\n+ self.reddit.request_fetch(id, kind, true, done);\n }\n \n pub async fn record_vote(\ndiff --git a/server/src/ui_action.rs b/server/src/ui_action.rs\nindex ef9ac873fc0442e0b960f144309c8e91124d7884..2047713762c932ac9bc325fe15624f2aecb3364d 100644\n--- a/server/src/ui_action.rs\n+++ b/server/src/ui_action.rs\n@@ -8,6 +8,16 @@ use thiserror::Error;\n \n pub const UI_RPC_FIELD: &str = \"__rpc__\";\n \n+/// What a `fetch_entity` action targets: the node itself, or its children.\n+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]\n+pub enum FetchTarget {\n+ #[default]\n+ #[serde(rename = \"self\")]\n+ SelfEntity,\n+ #[serde(rename = \"children\")]\n+ Children,\n+}\n+\n /// HTML form / fetch `POST /ui` payload after template fill and deserialization.\n #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n #[serde(tag = \"action\", rename_all = \"snake_case\")]\n@@ -26,9 +36,12 @@ pub enum HtmlUiAction {\n ParseQuery {\n query: String,\n },\n- /// Import entity data; `POST /ui` responds with `text/event-stream` (not JS).\n+ /// Import entity data; `POST /ui` responds with `text/event-stream` whose\n+ /// events carry JS snippets to `eval` (Idiomorph morphs), not JSON.\n FetchEntity {\n item: String,\n+ #[serde(default)]\n+ kind: FetchTarget,\n },\n }\n \ndiff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js\nindex fe7bfc657c0fccae5c596d005b0e807abefec677..62c9158ecb3c2140b83efd2b27ea706a9e0ae127 100644\n--- a/server/static/sorter_ui.js\n+++ b/server/static/sorter_ui.js\n@@ -8,48 +8,24 @@\n }\n }\n \n- function morphSelector(selector, html) {\n- var el = document.querySelector(selector);\n- if (el && typeof Idiomorph !== 'undefined') {\n- Idiomorph.morph(el, html);\n- }\n- }\n-\n- function handleSseEvent(eventType, data, form) {\n- if (eventType === 'fetching' || eventType === 'complete') {\n- try {\n- var msg = JSON.parse(data);\n- morphSelector(msg.selector || '#entity-section', msg.html);\n- } catch (err) {\n- console.warn('fetch morph parse', err);\n- }\n- }\n- if (eventType === 'complete' || eventType === 'error') {\n- var btn = form && form.querySelector('button[type=\"submit\"]');\n- if (btn) btn.disabled = false;\n- }\n- if (eventType === 'error') {\n- try {\n- var err = JSON.parse(data);\n- console.warn('fetch error:', err.message || data);\n- } catch (_e) {\n- console.warn('fetch error:', data);\n- }\n- }\n- }\n-\n- function consumeSseStream(response, form) {\n+ // Each SSE event's `data` is a JS snippet to eval (same as the non-stream\n+ // /ui responses). Parse the raw event stream, joining multi-line `data:`\n+ // fields, and eval each event as it arrives.\n+ function consumeSseStream(response) {\n var reader = response.body.getReader();\n var decoder = new TextDecoder();\n var buffer = '';\n- var eventType = '';\n var dataLines = [];\n \n function dispatch() {\n- if (!eventType && dataLines.length === 0) return;\n- handleSseEvent(eventType || 'message', dataLines.join('\\n'), form);\n- eventType = '';\n+ if (dataLines.length === 0) return;\n+ var js = dataLines.join('\\n');\n dataLines = [];\n+ try {\n+ evalJs(js);\n+ } catch (err) {\n+ console.warn('fetch eval failed', err);\n+ }\n }\n \n function pump() {\n@@ -65,11 +41,10 @@\n var line = parts[i].replace(/\\r$/, '');\n if (line === '') {\n dispatch();\n- } else if (line.indexOf('event:') === 0) {\n- eventType = line.slice(6).trim();\n } else if (line.indexOf('data:') === 0) {\n- dataLines.push(line.slice(5).trim());\n+ dataLines.push(line.slice(5).replace(/^ /, ''));\n }\n+ // `event:`/`id:`/`:` comment lines are ignored — data carries the JS.\n }\n return pump();\n });\n@@ -78,9 +53,13 @@\n return pump();\n }\n \n+ function isFetchForm(form) {\n+ return form.classList && form.classList.contains('fetch-entity-form');\n+ }\n+\n function postUiForm(form) {\n var btn = form.querySelector('button[type=\"submit\"]');\n- if (form.id === 'fetch-entity-form' && btn) {\n+ if (isFetchForm(form) && btn) {\n btn.disabled = true;\n }\n return fetch(form.action, {\n@@ -91,11 +70,11 @@\n }).then(function (resp) {\n var ct = resp.headers.get('content-type') || '';\n if (ct.indexOf('text/event-stream') !== -1) {\n- return consumeSseStream(resp, form);\n+ return consumeSseStream(resp);\n }\n return resp.text().then(evalJs);\n }).catch(function (err) {\n- if (form.id === 'fetch-entity-form' && btn) {\n+ if (isFetchForm(form) && btn) {\n btn.disabled = false;\n }\n console.warn('POST /ui failed', err);\ndiff --git a/test/fixtures/reddit/r_rust_listing.json b/test/fixtures/reddit/r_rust_listing.json\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..c478abe0331123f1f8922dd7269addcf0c302dc6\n--- /dev/null\n+++ b/test/fixtures/reddit/r_rust_listing.json\n@@ -0,0 +1,29 @@\n+{\n+ \"kind\": \"Listing\",\n+ \"data\": {\n+ \"after\": \"t3_bbb\",\n+ \"children\": [\n+ {\n+ \"kind\": \"t3\",\n+ \"data\": {\n+ \"title\": \"Announcing Rust 1.99\",\n+ \"permalink\": \"/r/rust/comments/aaa/announcing_rust_199/\",\n+ \"author\": \"alice\",\n+ \"selftext_html\": \"

release notes

\",\n+ \"thumbnail\": \"self\",\n+ \"subreddit\": \"rust\"\n+ }\n+ },\n+ {\n+ \"kind\": \"t3\",\n+ \"data\": {\n+ \"title\": \"What are you working on this week?\",\n+ \"permalink\": \"/r/rust/comments/bbb/what_are_you_working_on/\",\n+ \"author\": \"bob\",\n+ \"thumbnail\": \"default\",\n+ \"subreddit\": \"rust\"\n+ }\n+ }\n+ ]\n+ }\n+}\ndiff --git a/test/reddit_import.clj b/test/reddit_import.clj\nindex 17f2ee39e1498b0b6bb6a9d8a7e35608b0bd1b32..84cbdcf7965e50290313cbce2243a16b583d2097 100644\n--- a/test/reddit_import.clj\n+++ b/test/reddit_import.clj\n@@ -14,16 +14,22 @@\n (.getLocalPort s)))\n \n (defn- start-mock-reddit [port fixtures-dir]\n- (let [fixture (io/file fixtures-dir \"r_rust_about.json\")\n- body (.getBytes (slurp fixture) \"UTF-8\")\n+ (let [about (.getBytes (slurp (io/file fixtures-dir \"r_rust_about.json\")) \"UTF-8\")\n+ listing (.getBytes (slurp (io/file fixtures-dir \"r_rust_listing.json\")) \"UTF-8\")\n server (HttpServer/create (InetSocketAddress. \"127.0.0.1\" port) 0)\n handler\n (proxy [HttpHandler] []\n (handle [^HttpExchange exchange]\n- (.sendResponseHeaders exchange 200 (alength body))\n- (let [out (.getResponseBody exchange)]\n- (.write out body)\n- (.close out))))]\n+ ;; Route by path: `/r//about.json` is the subreddit entity,\n+ ;; `/r/.json` is the children listing.\n+ (let [path (.getPath (.getRequestURI exchange))\n+ body (if (str/includes? path \"/about\")\n+ about\n+ listing)]\n+ (.sendResponseHeaders exchange 200 (alength body))\n+ (let [out (.getResponseBody exchange)]\n+ (.write out body)\n+ (.close out)))))]\n (.createContext server \"/\" handler)\n (.setExecutor server nil)\n (.start server)\n@@ -44,12 +50,13 @@\n (do (Thread/sleep 200) (recur))\n false))))))\n \n-(defn- curl-fetch-ui-sse [base item]\n+(defn- curl-fetch-ui-sse [base item kind]\n (process/shell {:out :string :err :string}\n \"curl\" \"-sfN\" \"--max-time\" \"20\"\n \"-X\" \"POST\" (str base \"/ui\")\n \"--data-urlencode\"\n- (str \"__rpc__={\\\"action\\\":\\\"fetch_entity\\\",\\\"item\\\":\\\"\" item \"\\\"}\")))\n+ (str \"__rpc__={\\\"action\\\":\\\"fetch_entity\\\",\\\"item\\\":\\\"\" item\n+ \"\\\",\\\"kind\\\":\\\"\" kind \"\\\"}\")))\n \n (defn- wait-event-log [path ms]\n (let [deadline (+ (System/currentTimeMillis) ms)]\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[951dac0e] Feed RPC/CLI: scope by agent delegate instead of principal (#108)\n\n* feat(feed): key GetFeed by agent delegate, not principal\n\n- RpcCommand::GetFeed and FeedResponse use delegate (uuid:rig:model)\n- Accept legacy JSON field name \"actor\" via serde alias\n- Default cutoff finds last ingest with matching delegate\n- CLI feed subcommand takes DELEGATE; docs updated\n- Integration: ACL test uses post delegate; add delegate scoping test\n\nCo-authored-by: tommy \n\n* fix(feed): require auth and bound delegate; drop actor JSON alias\n\n- GetFeed rejects missing Authorization and delegates not bound to bearer principal\n- CLI feed sends saved bearer token like other authenticated RPCs\n- Integration: assert cross-user delegate rejected; no-auth rejected\n\nCo-authored-by: tommy \n\n* fix(feed): principal-wide catch-up includes delegate posts; optional CLI delegate\n\n- GetFeed without delegate: since = last ingest by bearer principal (any delegate)\n- CLI: optional positional delegate, defaults from SLUG_DELEGATE like forum post\n- Integration test for delegate-only posting + argless feed bounded since\n\nCo-authored-by: tommy \n\n---------\n\nCo-authored-by: Cursor Agent \n\nSide B — unified diff (full patch):\ndiff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter\nindex 9828cba4d9c17b7cce3de597d8724609b2b2adbe..59782bd1140dc9f31d44965bace833ea562e113e 100644\n--- a/cli/GUIDE.sorter\n+++ b/cli/GUIDE.sorter\n@@ -171,8 +171,9 @@ EOF\n # See current ranking\n npx slugsocial public garden children languages --json\n \n-# After a context reset: catch up (feed is keyed by principal username, stored form)\n-npx slugsocial feed yourusername\n+# Catch up: no delegate arg → since your last post as this user (any delegate). Or pass the chat's uuid:rig:model (or SLUG_DELEGATE).\n+npx slugsocial feed\n+npx slugsocial feed 550e8400-e29b-41d4-a716-446655440000:cursor:anthropic/claude-sonnet-4.5\n }\n \n ~/commands {\n@@ -207,8 +208,9 @@ identity poll Complete OAuth; saves bearer t\n \n whoami [--json] Resolve saved bearer token to principal\n \n-feed Activity since your last post (stored username, no @)\n-feed --since 2026-01-01 Override lower bound (Unix ms or YYYY-MM-DD)\n+feed Activity since you last posted (principal-wide)\n+feed Activity since this delegate last posted (or SLUG_DELEGATE)\n+feed … --since 2026-01-01 Override lower bound (Unix ms or YYYY-MM-DD)\n \n search Search items, threads, posts (public index)\n \ndiff --git a/cli/src/main.rs b/cli/src/main.rs\nindex b008cf377bb360aaae666d2dfd4b5e13dedb204a..122ee42189b2a6e47137be594556f8dddb7989a9 100644\n--- a/cli/src/main.rs\n+++ b/cli/src/main.rs\n@@ -146,20 +146,27 @@ enum Command {\n sub: RoomCmd,\n },\n \n- /// Show all activity since you last posted (global feed)\n+ /// Show activity since your last post, or since this delegate last posted (global feed)\n ///\n- /// Returns all ingests since this actor's last ingest, newest first.\n- /// Useful for agents to catch up on activity after a context reset.\n+ /// With **no delegate argument**: uses the timestamp of your **last ingest as this principal** (any\n+ /// `uuid:rig:model` or none), so an old chat that only has your token still gets a sane catch-up\n+ /// after you have been posting with `--delegate`.\n+ ///\n+ /// With a **delegate** (or `SLUG_DELEGATE`): cutoff is that delegate's last ingest only — use the same\n+ /// string as in that chat for per-session continuity.\n+ ///\n+ /// Requires your saved bearer token; an explicit delegate must be bound to your account.\n ///\n /// Examples:\n- /// npx slugsocial feed tommy\n- /// npx slugsocial feed tommy --since 2026-01-01\n+ /// npx slugsocial feed\n+ /// npx slugsocial feed 550e8400-e29b-41d4-a716-446655440000:cursor:anthropic/claude-sonnet-4.5\n+ /// npx slugsocial feed --since 2026-01-01\n Feed {\n- /// Principal username (stored form)\n- #[arg(value_name = \"ACTOR\")]\n- actor: String,\n+ /// Agent delegate (`uuid:rig:provider/model`); omit for principal-wide catch-up. Same env as forum post.\n+ #[arg(value_name = \"DELEGATE\", env = \"SLUG_DELEGATE\")]\n+ delegate: Option,\n /// Override the lower bound. Accepts Unix ms or YYYY-MM-DD.\n- /// Defaults to the actor's last ingest timestamp on the server.\n+ /// Defaults to the delegate's last ingest timestamp on the server.\n #[arg(long, value_name = \"DATE_OR_MS\")]\n since: Option,\n /// Max items to return (default: 10)\n@@ -1434,14 +1441,25 @@ async fn main() -> Result<()> {\n }\n }\n \n- Command::Feed { actor, since, limit, json } => {\n+ Command::Feed { delegate, since, limit, json } => {\n let client = http_client()?;\n+ let bearer = effective_bearer().ok_or_else(|| {\n+ anyhow!(\n+ \"no bearer token: run `slugsocial identity start --rig --model ` \\\n+ then `slugsocial identity poll `, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token\"\n+ )\n+ })?;\n+ let delegate = delegate\n+ .as_deref()\n+ .map(str::trim)\n+ .filter(|s| !s.is_empty())\n+ .map(|s| s.to_string());\n let batch = send_rpc(\n &client,\n base,\n- None,\n+ Some(&bearer),\n vec![RpcCommand::GetFeed {\n- actor,\n+ delegate,\n since: match since {\n Some(s) => Some(parse_ts(&s)?),\n None => None,\ndiff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs\nindex 422b520f527f64571f86f0ee3cb57edcbc21c0c3..936f55bac6abb69e672f6594385934688dc215e3 100644\n--- a/server/src/api/rpc.rs\n+++ b/server/src/api/rpc.rs\n@@ -1785,30 +1785,105 @@ pub async fn handle_rpc_batch(\n }\n },\n RpcCommand::GetFeed {\n- actor,\n+ delegate,\n since,\n limit,\n } => {\n const DEFAULT_LIMIT: usize = 50;\n const MAX_LIMIT: usize = 200;\n- match parse_username(&actor) {\n- Err(msg) => line_err(\"invalid actor\", Some(msg)),\n- Ok(actor_stored) => {\n- let reduced = state.reduced.read().await;\n- match principal_from_optional_bearer(&headers, &reduced) {\n- Err((e, h)) => line_err(e, h),\n- Ok(viewer) => {\n+ let reduced = state.reduced.read().await;\n+ match verify_bearer_principal(&headers, &reduced) {\n+ Err((_, m)) => line_err(m, None),\n+ Ok(viewer) => {\n+ let delegate_parsed: Option> = delegate\n+ .as_deref()\n+ .map(str::trim)\n+ .filter(|s| !s.is_empty())\n+ .map(parse_agent);\n+ match delegate_parsed {\n+ Some(Err(msg)) => {\n+ drop(reduced);\n+ line_err(\"invalid delegate\", Some(msg))\n+ }\n+ Some(Ok(delegate_stored)) => {\n+ let line = if reduced.agent_bindings.get(&delegate_stored) != Some(&viewer) {\n+ line_err(\n+ \"not your delegate\",\n+ Some(\"this delegate is not bound to your signed-in account\".into()),\n+ )\n+ } else {\n+ let since_default = reduced\n+ .ingests_ordered\n+ .iter()\n+ .rev()\n+ .filter_map(|id| reduced.ingests_by_id.get(id))\n+ .find(|ing| {\n+ if ing.delegate.as_deref() != Some(delegate_stored.as_str()) {\n+ return false;\n+ }\n+ let scope = scope_from_room_wire(&ing.room_id);\n+ can_view_scope(&reduced, &scope, Some(viewer.as_str()))\n+ })\n+ .map(|ing| ing.ts);\n+ let since = since.or(since_default);\n+ let cutoff = since.unwrap_or(0);\n+ let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);\n+ let matching: Vec<&str> = reduced.ingests_ordered.iter().rev()\n+ .map(|id| id.as_str())\n+ .take_while(|id| reduced.ingests_by_id.get(*id).map_or(false, |ing| ing.ts > cutoff))\n+ .filter(|id| {\n+ reduced.ingests_by_id.get(*id).is_some_and(|ing| {\n+ let scope = scope_from_room_wire(&ing.room_id);\n+ can_view_scope(&reduced, &scope, Some(viewer.as_str()))\n+ })\n+ })\n+ .collect();\n+ let total = matching.len();\n+ let posts: Vec = matching.into_iter()\n+ .take(limit)\n+ .filter_map(|id| reduced.ingests_by_id.get(id))\n+ .filter(|ing| !reduced.redacted_posts.contains(&ing.id))\n+ .map(|ing| {\n+ let scope = scope_from_room_wire(&ing.room_id);\n+ let thread_post_index = reduced\n+ .ingests_by_scope_thread\n+ .get(&(scope, ing.thread_tag.clone()))\n+ .and_then(|q| {\n+ q.iter().rev().position(|pid| pid == &ing.id).map(|i| i + 1)\n+ });\n+ FeedPost {\n+ ts: ing.ts,\n+ id: ing.id.clone(),\n+ thread: Some(ing.thread_tag.clone()),\n+ thread_post_index,\n+ body: ing.raw.clone(),\n+ }\n+ })\n+ .collect();\n+ line_ok(RpcResult::Feed(FeedResponse {\n+ delegate: Some(delegate_stored),\n+ since,\n+ posts,\n+ total,\n+ }))\n+ };\n+ drop(reduced);\n+ line\n+ }\n+ None => {\n+ // Session catch-up: last time *you* posted anything (delegate or not), so revisiting\n+ // an old chat with only a token still gets a sane cutoff.\n let since_default = reduced\n .ingests_ordered\n .iter()\n .rev()\n .filter_map(|id| reduced.ingests_by_id.get(id))\n .find(|ing| {\n- if ing.principal != actor_stored {\n+ if ing.principal != viewer {\n return false;\n }\n let scope = scope_from_room_wire(&ing.room_id);\n- can_view_scope(&reduced, &scope, viewer.as_deref())\n+ can_view_scope(&reduced, &scope, Some(viewer.as_str()))\n })\n .map(|ing| ing.ts);\n let since = since.or(since_default);\n@@ -1820,7 +1895,7 @@ pub async fn handle_rpc_batch(\n .filter(|id| {\n reduced.ingests_by_id.get(*id).is_some_and(|ing| {\n let scope = scope_from_room_wire(&ing.room_id);\n- can_view_scope(&reduced, &scope, viewer.as_deref())\n+ can_view_scope(&reduced, &scope, Some(viewer.as_str()))\n })\n })\n .collect();\n@@ -1846,12 +1921,14 @@ pub async fn handle_rpc_batch(\n }\n })\n .collect();\n- line_ok(RpcResult::Feed(FeedResponse {\n- actor: actor_stored,\n+ let line = line_ok(RpcResult::Feed(FeedResponse {\n+ delegate: None,\n since,\n posts,\n total,\n- }))\n+ }));\n+ drop(reduced);\n+ line\n }\n }\n }\ndiff --git a/server/tests/integration.rs b/server/tests/integration.rs\nindex 831722430d6b41308e3f03d485cd62f887ace799..124b7ca000384d5ad4c3062630050dbe587abffe 100644\n--- a/server/tests/integration.rs\n+++ b/server/tests/integration.rs\n@@ -324,13 +324,13 @@ async fn test_private_room_read_requires_explicit_view_capability() {\n \"search should hide private posts without view capability\"\n );\n \n- // Feed should likewise hide private posts.\n+ // Feed should likewise hide private posts (keyed by delegate on those ingests).\n let feed = rpc_batch(\n &client,\n addr,\n Some(&bearer),\n serde_json::json!([{\n- \"GetFeed\": { \"actor\": \"testuser\", \"limit\": 20 }\n+ \"GetFeed\": { \"delegate\": \"00000000-0000-0000-0000-000000000000:test:local/test\", \"limit\": 20 }\n }]),\n )\n .await;\n@@ -343,6 +343,179 @@ async fn test_private_room_read_requires_explicit_view_capability() {\n );\n }\n \n+#[tokio::test]\n+async fn test_feed_since_last_post_is_scoped_to_delegate() {\n+ let (addr, _tmp, _log, _handle) = create_test_server().await;\n+ let client = reqwest::Client::new();\n+ let bearer = test_bearer();\n+\n+ let d1 = \"00000000-0000-0000-0000-0000000000a1:feedtest:local/model-a\";\n+ let d2 = \"00000000-0000-0000-0000-0000000000a2:feedtest:local/model-b\";\n+\n+ let p1 = rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([{\n+ \"Post\": {\n+ \"room\": \"public\",\n+ \"thread_tag\": \"feed-delegate-test\",\n+ \"delegate\": d1,\n+ \"text\": \"#feed-delegate-test\\nalpha marker for d1\\n\",\n+ \"return_rank_diff\": false\n+ }\n+ }]),\n+ )\n+ .await;\n+ assert_eq!(p1[\"results\"][0][\"ok\"], true, \"first post: {:?}\", p1);\n+\n+ let p2 = rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([{\n+ \"Post\": {\n+ \"room\": \"public\",\n+ \"thread_tag\": \"feed-delegate-test\",\n+ \"delegate\": d2,\n+ \"text\": \"#feed-delegate-test\\nbeta marker for d2\\n\",\n+ \"return_rank_diff\": false\n+ }\n+ }]),\n+ )\n+ .await;\n+ assert_eq!(p2[\"results\"][0][\"ok\"], true, \"second post: {:?}\", p2);\n+\n+ // Since d1 last posted first, the feed for d1 should include everything after that (here: d2's post).\n+ let feed = rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([{\n+ \"GetFeed\": { \"delegate\": d1, \"limit\": 20 }\n+ }]),\n+ )\n+ .await;\n+ let line = &feed[\"results\"][0];\n+ assert_eq!(line[\"ok\"], true, \"GetFeed failed: {:?}\", line);\n+ let delegate = line[\"result\"][\"Feed\"][\"delegate\"].as_str().unwrap();\n+ assert_eq!(delegate, d1);\n+ let bodies: String = line[\"result\"][\"Feed\"][\"posts\"]\n+ .as_array()\n+ .unwrap()\n+ .iter()\n+ .filter_map(|p| p[\"body\"].as_str())\n+ .collect::>()\n+ .join(\"\\n\");\n+ assert!(\n+ bodies.contains(\"beta marker for d2\"),\n+ \"expected d2's post after d1's cutoff, got: {bodies}\"\n+ );\n+ assert!(\n+ !bodies.contains(\"alpha marker for d1\"),\n+ \"d1's own prior post should not appear after cutoff, got: {bodies}\"\n+ );\n+\n+ let d_other = \"00000000-0000-0000-0000-0000000000ff:feedtest:local/other\";\n+ let steal = rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([{\n+ \"GetFeed\": { \"delegate\": d_other, \"limit\": 5 }\n+ }]),\n+ )\n+ .await;\n+ let steal_line = &steal[\"results\"][0];\n+ assert_eq!(steal_line[\"ok\"], false, \"expected rejection for unbound delegate: {:?}\", steal_line);\n+ assert_eq!(steal_line[\"error\"], \"not your delegate\");\n+\n+ let no_auth = rpc_batch(\n+ &client,\n+ addr,\n+ None,\n+ serde_json::json!([{\n+ \"GetFeed\": { \"delegate\": d1, \"limit\": 5 }\n+ }]),\n+ )\n+ .await;\n+ let na = &no_auth[\"results\"][0];\n+ assert_eq!(na[\"ok\"], false, \"GetFeed without bearer: {:?}\", na);\n+ assert_eq!(na[\"error\"], \"missing Authorization header\");\n+}\n+\n+#[tokio::test]\n+async fn test_feed_without_delegate_uses_principal_last_post_including_delegate() {\n+ let (addr, _tmp, _log, _handle) = create_test_server().await;\n+ let client = reqwest::Client::new();\n+ let bearer = test_bearer();\n+\n+ let d = \"00000000-0000-0000-0000-0000000000b1:principalfeed:local/model\";\n+ let p1 = rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([{\n+ \"Post\": {\n+ \"room\": \"public\",\n+ \"thread_tag\": \"principal-feed-test\",\n+ \"delegate\": d,\n+ \"text\": \"#principal-feed-test\\nfirst delegate post\\n\",\n+ \"return_rank_diff\": false\n+ }\n+ }]),\n+ )\n+ .await;\n+ assert_eq!(p1[\"results\"][0][\"ok\"], true, \"{:?}\", p1);\n+\n+ let p2 = rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([{\n+ \"Post\": {\n+ \"room\": \"public\",\n+ \"thread_tag\": \"principal-feed-test\",\n+ \"delegate\": d,\n+ \"text\": \"#principal-feed-test\\nsecond delegate post\\n\",\n+ \"return_rank_diff\": false\n+ }\n+ }]),\n+ )\n+ .await;\n+ assert_eq!(p2[\"results\"][0][\"ok\"], true, \"{:?}\", p2);\n+\n+ // Argless GetFeed: cutoff is last ingest by this principal (even if every post used --delegate),\n+ // so revisiting an old chat with only a token still gets a bounded \"since\", not full history.\n+ let feed = rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([{\n+ \"GetFeed\": { \"limit\": 20 }\n+ }]),\n+ )\n+ .await;\n+ let line = &feed[\"results\"][0];\n+ assert_eq!(line[\"ok\"], true, \"{:?}\", line);\n+ assert!(\n+ line[\"result\"][\"Feed\"][\"delegate\"].is_null(),\n+ \"principal-wide feed omits delegate in JSON: {:?}\",\n+ line[\"result\"][\"Feed\"]\n+ );\n+ assert!(\n+ line[\"result\"][\"Feed\"][\"since\"].is_number(),\n+ \"expected since from principal's last post (including delegate ingests), got: {:?}\",\n+ line[\"result\"][\"Feed\"][\"since\"]\n+ );\n+ let posts = line[\"result\"][\"Feed\"][\"posts\"].as_array().unwrap();\n+ assert!(\n+ posts.is_empty(),\n+ \"nothing is strictly newer than the latest own post; got {} posts\",\n+ posts.len()\n+ );\n+}\n+\n #[tokio::test]\n async fn test_private_room_thread_urls_use_t_segment() {\n let (addr, _tmp, _log, _handle) = create_test_server().await;\ndiff --git a/types/src/lib.rs b/types/src/lib.rs\nindex 5fc867bf2af84c2ca63fa1cdd03413110ad784a3..ff332ca8a752db3db13c59c94510f09848978134 100644\n--- a/types/src/lib.rs\n+++ b/types/src/lib.rs\n@@ -228,9 +228,10 @@ pub struct VoteRow {\n /// Response for the feed endpoint — all ingests since a cutoff, newest first.\n #[derive(Debug, Serialize, Deserialize)]\n pub struct FeedResponse {\n- /// Principal username this feed is scoped to (stored form, no `@`).\n- pub actor: String,\n- /// The lower-bound timestamp used (actor's last ingest, ms). None if actor has never posted.\n+ /// When set, feed cutoff is scoped to this agent delegate (`uuid:rig:provider/model`). Omitted when using principal-wide catch-up (`GetFeed` without `delegate`).\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub delegate: Option,\n+ /// Lower-bound timestamp (ms): last matching ingest for the chosen scope. None if no prior matching ingest.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub since: Option,\n pub posts: Vec,\n@@ -401,7 +402,9 @@ pub enum RpcCommand {\n query: String,\n },\n GetFeed {\n- actor: String,\n+ /// When omitted or empty, feed uses the bearer principal's last ingest as the cutoff (any delegate or none).\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ delegate: Option,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n since: Option,\n #[serde(default)]\n","role":"user"}],"model":"~x-ai/grok-latest"}