Side A implements a substantive, correctly-designed feature change (rescoping GetFeed by agent delegate instead of principal, adding auth checks, delegate binding validation, CLI updates, and multiple integration tests) with real lasting value to the API and security model. Side B is a trivial two-line cosmetic tweak (removing a heading and changing a span to an anchor tag) with negligible impact.
constitution · epochs · watch · epoch 3
c_8af10ca56dff (tommy-mor) vs c_7fc30b2498be (tommy-mor)
download prompt · raw event · cmp_f50e787a3e852d
council reasoning
A redesigns GetFeed around agent delegates (optional principal-wide catch-up), enforces auth/delegate binding, and ships CLI/docs plus integration tests—real product and security substance. B only drops a static h2 and swaps a disabled paginator span for an <a href="#">, a negligible UI nit with almost no lasting impact.
Side A redesigns feed scoping from principal to agent delegate, updates the RPC and CLI interfaces, enforces authenticated access with delegate ownership checks, preserves principal-wide catch-up when no delegate is provided, and adds comprehensive integration tests covering delegate scoping, authorization, and fallback behavior. Side B only makes minor HTML tweaks by removing a heading and changing a disabled paginator element from a span to an anchor, with little lasting impact on project behavior.
sides
A — c_8af10ca56dff (tommy-mor)
message
[951dac0e] Feed RPC/CLI: scope by agent delegate instead of principal (#108) * feat(feed): key GetFeed by agent delegate, not principal - RpcCommand::GetFeed and FeedResponse use delegate (uuid:rig:model) - Accept legacy JSON field name "actor" via serde alias - Default cutoff finds last ingest with matching delegate - CLI feed subcommand takes DELEGATE; docs updated - Integration: ACL test uses post delegate; add delegate scoping test Co-authored-by: tommy <thmorriss@gmail.com> * fix(feed): require auth and bound delegate; drop actor JSON alias - GetFeed rejects missing Authorization and delegates not bound to bearer principal - CLI feed sends saved bearer token like other authenticated RPCs - Integration: assert cross-user delegate rejected; no-auth rejected Co-authored-by: tommy <thmorriss@gmail.com> * fix(feed): principal-wide catch-up includes delegate posts; optional CLI delegate - GetFeed without delegate: since = last ingest by bearer principal (any delegate) - CLI: optional positional delegate, defaults from SLUG_DELEGATE like forum post - Integration test for delegate-only posting + argless feed bounded since Co-authored-by: tommy <thmorriss@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
diff preview
diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter
index 9828cba4d9c17b7cce3de597d8724609b2b2adbe..59782bd1140dc9f31d44965bace833ea562e113e 100644
--- a/cli/GUIDE.sorter
+++ b/cli/GUIDE.sorter
@@ -171,8 +171,9 @@ EOF
# See current ranking
npx slugsocial public garden children languages --json
-# After a context reset: catch up (feed is keyed by principal username, stored form)
-npx slugsocial feed yourusername
+# 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).
+npx slugsocial feed
+npx slugsocial feed 550e8400-e29b-41d4-a716-446655440000:cursor:anthropic/claude-sonnet-4.5
}
~/commands {
@@ -207,8 +208,9 @@ identity poll <session> Complete OAuth; saves bearer t
whoami [--json] Resolve saved bearer token to principal
-feed <username> Activity since your last post (stored username, no @)
-feed <username> --since 2026-01-01 Override lower bound (Unix ms or YYYY-MM-DD)
+feed Activity since you last posted (principal-wide)
+feed <uuid:rig:model> Activity since this delegate last posted (or SLUG_DELEGATE)
+feed … --since 2026-01-01 Override lower bound (Unix ms or YYYY-MM-DD)
search <query> Search items, threads, posts (public index)
diff --git a/cli/src/main.rs b/cli/src/main.rs
index b008cf377bb360aaae666d2dfd4b5e13dedb204a..122ee42189b2a6e47137be594556f8dddb7989a9 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -146,20 +146,27 @@ enum Command {
sub: RoomCmd,
},
- /// Show all activity since you last posted (global feed)
+ /// Show activity since your last post, or since this delegate last posted (global feed)
///
- /// Returns all ingests since this actor's last ingest, newest first.
- /// Useful for agents to catch up on activity after a context reset.
+ /// With **no delegate argument**: uses the timestamp of your **last ingest as this principal** (any
+ /// `uuid:rig:model` or none), so an old chat that only has your token still gets a sane catch-up
+ /// after you have been posting with `--delegate`.
+ ///
+ /// With a **delegate** (or `SLUG_DELEGATE`): cutoff is that delegate's last ingest only — use the same
+ /// string as in that chat for per-session continuity.
+ ///
+ /// Requires your saved bearer token; an explicit delegate must be bound to your account.
///
/// Examples:
- /// npx slugsocial feed tommy
- /// npx slugsocial feed tommy --since 2026-01-01
+ /// npx slugsocial feed
+ /// npx slugsocial feed 550e8400-e29b-41d4-a716-446655440000:cursor:anthropic/claude-sonnet-4.5
+ /// npx slugsocial feed --since 2026-01-01
Feed {
- /// Principal username (stored form)
- #[arg(value_name = "ACTOR")]
- actor: String,
+ /// Agent delegate (`uuid:rig:provider/model`); omit for principal-wide catch-up. Same env as forum post.
+ #[arg(value_name = "DELEGATE", env = "SLUG_DELEGATE")]
+ delegate: Option<String>,
/// Override the lower bound. Accepts Unix ms or YYYY-MM-DD.
- /// Defaults to the actor's last ingest timestamp on the server.
+ /// Defaults to the delegate's last ingest timestamp on the server.
#[arg(long, value_name = "DATE_OR_MS")]
since: Option<String>,
/// Max items to return (default: 10)
@@ -1434,14 +1441,25 @@ async fn main() -> Result<()> {
}
}
- Command::Feed { actor, since, limit, json } => {
+ Command::Feed { delegate, since, limit, json } => {
let client = http_client()?;
+ let bearer = effective_bearer().ok_or_else(|| {
+ anyhow!(
+ "no bearer token: run `slugsocial identity start --rig <rig> --model <model>` \
+ then `slugsocial identity poll <session>`, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token"
+ )
+ })?;
+ let delegate = delegate
+ .as_deref()
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string());
let batch = send_rpc(
&client,
base,
- None,
+ Some(&bearer),
vec![RpcCommand::GetFeed {
- actor,
+ delegate,
since: match since {
Some(s) => Some(parse_ts(&s)?),
None => None,
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index 422b520f527f64571f86f0ee3cb57edcbc21c0c3..936f55bac6abb69e672f6594385934688dc215e3 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -1785,30 +1785,105 @@ pub async fn handle_rpc_batch(
}
},
RpcCommand::GetFeed {
- actor,
+ delegate,
since,
limit,
} => {
const DEFAULT_LIMIT: usize = 50;
const MAX_LIMIT: usize = 200;
- match parse_username(&actor) {
- Err(msg) => line_err("invalid actor", Some(msg)),
- Ok(actor_stored) => {
- let reduced = state.reduced.read().await;
- match principal_from_optional_bearer(&headers, &reduced) {
- Err((e, h)) => line_err(e, h),
- Ok(viewer) => {
+ let reduced = state.reduced.read().await;
+ match verify_bearer_principal(&headers, &reduced) {
+ Err((_, m)) => line_err(m, None),
+ Ok(viewer) => {
+ let delegate_parsed: Option<Result<String, String>> = delegate
+ .as_deref()
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(parse_agent);
+ match delegate_parsed {
+ Some(Err(msg)) => {
+ drop(reduced);
+ line_err("invalid delegate", Some(msg))
+ }
+ Some(Ok(delegate_stored)) => {
+ let line = if reduced.agent_bindings.get(&delegate_stored) != Some(&viewer) {
+ line_err(
+ "not your delegate",
+ Some("this delegate is not bound to your signed-in account".into()),
+ )
+ } else {
+ let since_default = reduced
+ .ingests_ordered
+ .iter()
+ .rev()
+ .filter_map(|id| reduced.ingests_by_id.get(id))
+ .find(|ing| {
+ if ing.delegate.as_deref() != Some(delegate_stored.as_str()) {
+ return false;
+ }
+ let scope = scope_from_room_wire(&ing.room_id);
+ can_view_scope(&reduced, &scope, Some(viewer.as_str()))
+ })
+ .map(|ing| ing.ts);
+ let since = since.or(since_default);
+ let cutoff = since.unwrap_or(0);
+ let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
+ let matching: Vec<&str> = reduced.ingests_ordered.iter().rev()
+ .map(|id| id.as_str())
+ .take_while(|id| reduced.ingests_by_id.get(*id).map_or(false, |ing| ing.ts > cutoff))
+ .filter(|id| {
+ reduced.ingests_by_id.get(*id).is_some_and(|ing| {
+ let scope = scope_from_room_wire(&ing.room_id);
+ can_view_scope(&reduced, &scope, Some(viewer.as_str()))
+ })
+ })
+ .collect();
+ let total = matching.len();
+ let posts: Vec<FeedPost> = matching.into_iter()
+ .take(limit)
+ .filter_map(|id| reduced.ingests_by_id.get(id))
+ .filter(|ing| !reduced.redacted_posts.contains(&ing.id))
+ .map(|ing| {
+ let scope = scope_from_room_wire(&ing.room_id);
+ let thread_post_index = reduced
+ .ingests_by_scope_thread
+ .get(&(scope, ing.thread_tag.clone()))
+ .and_then(|q| {
+ q.iter().rev().position(|pid| pid == &ing.id).map(|i| i + 1)
+ });
+ FeedPost {
+ ts: ing.ts,
+ id: ing.id.clone(),
+ thread: Some(ing.thread_tag.clone()),
+ thread_post_index,
+ body: ing.raw.clone(),
+ }
+ })
+ .collect();
+ line_ok(RpcResult::Feed(FeedResponse {
+ delegate: Some(delegate_stored),
+ since,
+ posts,
+ total,
+ }))
+ };
+ drop(reduced);
+ line
+ }
+ None => {
+ // Session catch-up: last time *you* posted anything (delegate or not), so revisiting
+ // an old chat with only a token still gets a sane cutoff.
let since_default = reduced
.ingests_ordered
.iter()
.rev()
.filter_map(|id| reduced.ingests_by_id.get(id))
.find(|ing| {
- if ing.principal != actor_stored {
+ if ing.principal != viewer {
return false;
}
let scope = scope_from_room_wire(&ing.room_id);
- can_view_scope(&reduced, &scope, viewer.as_deref())
+ can_view_scope(&reduced, &scope, Some(viewer.as_st
… preview truncated; 9,880 characters omittedB — c_7fc30b2498be (tommy-mor)
message
[963c2670] tiny
diff preview
diff --git a/server/src/html/forum.rs b/server/src/html/forum.rs
index 368b1016eb43d83e9ff39464ff6164fa9a8b558a..f1114562c8c0196f78a008028b46d1fda57479f9 100644
--- a/server/src/html/forum.rs
+++ b/server/src/html/forum.rs
@@ -618,7 +618,6 @@ pub async fn home(
}
}
}
- h2 { "public threads" }
p class="muted" { "dark = time-ordered · light = vote-ranked" }
(render_thread_feed(Some(&nav), "thread-feed", &public_rows, now))
(new_thread_form_public(show_forms))
@@ -658,7 +657,7 @@ fn render_thread_paginator(nav: &ThreadNav, tag: &str, offset: usize, total: usi
@if let Some(o) = older_offset {
a href=(nav.thread_page_url(tag, o)) class="post-nav-btn" { "← older" }
} @else {
- span class="post-nav-btn disabled" { "← older" }
+ a href="#" class="post-nav-btn disabled" { "← older" }
}
span class="post-nav-pos muted" {
(offset + 1) "–" (total.min(offset + PAGE_SIZE)) " / " (total)
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.