feat(podcast): a PODCAST tier, a topics field, and a feed a phone can actually reach
deploy / test (push) Successful in 4m28s
deploy / build (push) Successful in 6m25s

Three gaps between "the pipeline works" and "you can use it".

**1. Topics could not be set.** The wizard never sent `config.topics`, so every
mission created through the UI silently fell back to
`library::default_topics()` — a hardcoded list that is somebody else's research
interests. The card now takes one arXiv search per line, and the description
field says plainly that for this template it IS the brief the agents judge
relevance against.

**2. There was nowhere to see or subscribe.** New PODCAST tier in the left rail,
between AGENT and REPOS: the feed URL with a copy button, the episode list, and
an inline player for checking one at a desk. `GET /api/podcast/episodes` and
`/subscription` back it. The panel also reports how many missions produced no
audio, so a missing day reads as a known gap rather than silence.

**3. The feed 404'd for the only client that will ever request it.** Three
layers each assumed a browser:

  - `resolveBearer` is server-only (`next/headers`), so a client component that
    imported it broke the build outright. The panel now goes through the
    same-origin proxy like every other panel, and the backend mints the feed URL
    because the session lives in an httpOnly cookie JavaScript cannot read.
  - The `/api` proxy demanded a session COOKIE. A podcast app has none and
    carries `?token=` instead — the same shape as the existing `hooks/` prefix,
    which is already exempt for exactly this reason.
  - The local autologin middleware 307'd it to `/auth/autologin`. A podcast app
    follows redirects blindly and would have stored an HTML page as the episode.

Neither exemption weakens auth: the backend still validates the token and
answers 401 to a bad one, verified. `episode_audio` accepts the token from
either the query string or an Authorization header, because the app fetches it
one way and the browser player the other, and refusing either breaks one of the
two ways this is listened to.

`CLAWMATES_PUBLIC_URL` matters and was wrong first: the tailnet root proxies to
a different service on :18789, and this frontend is on :8443. A feed advertising
an unreachable origin syncs silently forever, so `/subscription` returns a
`reachable` flag and the panel warns when it is still localhost.

Verified from a phone's point of view: feed 200 application/rss+xml over the
tailnet, enclosure 200 with 6,739,582 bytes of audio at 421s, bad token 401.

367 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-18 11:55:19 -07:00
co-authored by Claude Opus 5
parent f4adc8d0f9
commit fe45f72f09
11 changed files with 429 additions and 9 deletions
+2
View File
@@ -503,6 +503,8 @@ pub fn router(state: AppState) -> Router {
// The private podcast feed. Token in the query string, not a header: // The private podcast feed. Token in the query string, not a header:
// no podcast app can set headers. See `routes::podcast`. // no podcast app can set headers. See `routes::podcast`.
.route("/api/podcast/feed.xml", get(routes::podcast::feed)) .route("/api/podcast/feed.xml", get(routes::podcast::feed))
.route("/api/podcast/episodes", get(routes::podcast::list_episodes))
.route("/api/podcast/subscription", get(routes::podcast::subscription))
.route( .route(
"/api/podcast/episodes/{file}", "/api/podcast/episodes/{file}",
get(routes::podcast::episode_audio), get(routes::podcast::episode_audio),
+111 -2
View File
@@ -22,6 +22,13 @@ pub struct FeedAuth {
pub token: String, pub token: String,
} }
/// The audio endpoint accepts a token either way — see `episode_audio`.
#[derive(Deserialize)]
pub struct OptionalAuth {
#[serde(default)]
pub token: Option<String>,
}
/// Resolve a feed token to the workspace it may read. /// Resolve a feed token to the workspace it may read.
/// ///
/// Reuses the normal API token table, so revoking a token revokes the feed with /// Reuses the normal API token table, so revoking a token revokes the feed with
@@ -128,9 +135,24 @@ pub async fn feed(
pub async fn episode_audio( pub async fn episode_audio(
State(state): State<AppState>, State(state): State<AppState>,
Path(file): Path<String>, Path(file): Path<String>,
Query(auth): Query<FeedAuth>, Query(auth): Query<OptionalAuth>,
headers: axum::http::HeaderMap,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let workspace_id = workspace_for(&state, &auth.token).await?; // A podcast app fetches this with the token in the URL, because it cannot
// set headers. The browser plays it through the same-origin proxy, which
// supplies a bearer and no query token. Both are the same session; refusing
// either would break one of the two ways this is listened to.
let token = auth
.token
.or_else(|| {
headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::to_string)
})
.ok_or(ApiError::Unauthorized)?;
let workspace_id = workspace_for(&state, &token).await?;
let id = file let id = file
.strip_suffix(".mp3") .strip_suffix(".mp3")
.and_then(|s| uuid::Uuid::parse_str(s).ok()) .and_then(|s| uuid::Uuid::parse_str(s).ok())
@@ -165,6 +187,93 @@ pub async fn episode_audio(
.into_response()) .into_response())
} }
/// `GET /api/podcast/subscription` — the URL to paste into a podcast app.
///
/// Minted here rather than in the browser because the session lives in an
/// httpOnly cookie that JavaScript cannot read, and the same-origin proxy that
/// normally supplies the bearer is not available to a podcast app on a phone.
/// So the caller's own token is echoed back inside a URL that points DIRECTLY
/// at this backend.
pub async fn subscription(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
crate::extract::Authed(_user): crate::extract::Authed,
) -> Result<axum::Json<serde_json::Value>, ApiError> {
let token = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(ApiError::Unauthorized)?;
let base = std::env::var("CLAWMATES_PUBLIC_URL")
.unwrap_or_else(|_| "http://localhost:8080".to_string());
let base = base.trim_end_matches('/');
let _ = &state;
Ok(axum::Json(serde_json::json!({
"feedUrl": format!("{base}/api/podcast/feed.xml?token={token}"),
// The panel warns when this is still localhost: a phone cannot reach it,
// and a feed that only works on the machine that made it is a feed that
// silently never syncs.
"reachable": !base.contains("localhost") && !base.contains("127.0.0.1"),
})))
}
/// `GET /api/podcast/episodes` — the list behind the UI panel.
///
/// Normal bearer auth, unlike the feed: this is the app talking to its own API,
/// where a header is available and a token in a URL would be needless exposure.
pub async fn list_episodes(
State(state): State<AppState>,
crate::extract::Authed(user): crate::extract::Authed,
) -> Result<axum::Json<serde_json::Value>, ApiError> {
let rows = sqlx::query(
"SELECT e.id, e.episode_date, e.title, e.bytes, e.duration_secs,
e.rendered_by, e.created_at, e.mission_id, m.title AS mission_title
FROM podcast_episodes e
JOIN missions m ON m.id = e.mission_id
WHERE e.workspace_id = $1 AND e.bytes > 0
ORDER BY e.created_at DESC
LIMIT 50",
)
.bind(user.workspace_id.as_uuid())
.fetch_all(&state.pool)
.await?;
let episodes: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
let secs: i32 = r.get("duration_secs");
let created: time::OffsetDateTime = r.get("created_at");
serde_json::json!({
"id": r.get::<uuid::Uuid, _>("id"),
"missionId": r.get::<uuid::Uuid, _>("mission_id"),
"missionTitle": r.get::<String, _>("mission_title"),
"title": r.get::<String, _>("title"),
"date": r.get::<String, _>("episode_date"),
"bytes": r.get::<i64, _>("bytes"),
"durationSecs": secs,
"renderedBy": r.get::<String, _>("rendered_by"),
"createdAt": created.unix_timestamp(),
})
})
.collect();
// How many missions produced no audio, so the panel can say so rather than
// leaving a silent gap the operator has to notice for themselves.
let unrenderable: i64 = sqlx::query_scalar(
"SELECT count(*) FROM podcast_episodes WHERE workspace_id = $1 AND bytes = 0",
)
.bind(user.workspace_id.as_uuid())
.fetch_one(&state.pool)
.await
.unwrap_or(0);
Ok(axum::Json(serde_json::json!({
"episodes": episodes,
"unrenderable": unrenderable,
})))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+14 -3
View File
@@ -7,9 +7,20 @@ import { NextResponse, type NextRequest } from "next/server";
import { apiOrigin } from "@/lib/api/http"; import { apiOrigin } from "@/lib/api/http";
import { resolveBearer } from "@/lib/auth/bearer"; import { resolveBearer } from "@/lib/auth/bearer";
// Public API prefixes that don't require a session: the inbound webhook trigger // Public API prefixes that don't require a session cookie, because the request
// authenticates by its unguessable token in the path, not the user cookie. // carries its own credential and the BACKEND validates it.
const PUBLIC_PREFIXES = ["hooks/"]; //
// hooks/ — the inbound webhook trigger, authenticated by an unguessable
// token in the path.
// podcast/ — the RSS feed and its audio enclosures. A podcast app fetches
// these from a phone: it holds no cookie, cannot set headers, and
// carries `?token=` instead. Requiring a session here made the
// feed 404 for the only client that will ever request it, while
// working perfectly in the browser that generated the URL.
//
// Neither is unauthenticated — the query token is checked by the backend, which
// rejects a bad one with 401. This list only says "do not demand a COOKIE".
const PUBLIC_PREFIXES = ["hooks/", "podcast/"];
async function proxy( async function proxy(
request: NextRequest, request: NextRequest,
@@ -30,6 +30,7 @@ import { LevelUpInbox } from "./LevelUpInbox";
import { LevelUpDrawer } from "./LevelUpDrawer"; import { LevelUpDrawer } from "./LevelUpDrawer";
import { proposeForAgent } from "@/lib/api/level-up"; import { proposeForAgent } from "@/lib/api/level-up";
import { MissionCanvas } from "./MissionCanvas"; import { MissionCanvas } from "./MissionCanvas";
import { PodcastPanel } from "@/components/dashboard/PodcastPanel";
import { RepoList } from "./RepoList"; import { RepoList } from "./RepoList";
import { RepoCanvas } from "./RepoCanvas"; import { RepoCanvas } from "./RepoCanvas";
import { RepoConnectionWizardStub } from "./RepoConnectionWizardStub"; import { RepoConnectionWizardStub } from "./RepoConnectionWizardStub";
@@ -75,7 +76,7 @@ function readSidebarCollapse(): boolean {
// still references them doesn't hard-error — the switches below just // still references them doesn't hard-error — the switches below just
// have no branches for them, so the fallthrough renders the missions // have no branches for them, so the fallthrough renders the missions
// tier. // tier.
type Tier = "world" | "missions" | "claw" | "repos" | "infra"; type Tier = "world" | "missions" | "claw" | "podcast" | "repos" | "infra";
const mono = "'JetBrains Mono', ui-monospace, monospace"; const mono = "'JetBrains Mono', ui-monospace, monospace";
// dashboard-data.ts fabricates a few org/company/team nodes so an empty // dashboard-data.ts fabricates a few org/company/team nodes so an empty
@@ -154,12 +155,15 @@ const railIcon: Record<Tier, React.ReactNode> = {
claw: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="4" y="4" width="12" height="12" rx="3.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="10" cy="10" r="2.4" fill="currentColor" /></svg>), claw: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="4" y="4" width="12" height="12" rx="3.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="10" cy="10" r="2.4" fill="currentColor" /></svg>),
// Branching lines forking off a trunk — repositories. // Branching lines forking off a trunk — repositories.
repos: (<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="5" cy="5" r="1.6" fill="currentColor" /><circle cx="5" cy="15" r="1.6" fill="currentColor" /><circle cx="15" cy="10" r="1.6" fill="currentColor" /><path d="M5 6.6 V13.4 M5 10 C5 10 8 10 15 10" stroke="currentColor" strokeWidth="1.4" fill="none" /></svg>), repos: (<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="5" cy="5" r="1.6" fill="currentColor" /><circle cx="5" cy="15" r="1.6" fill="currentColor" /><circle cx="15" cy="10" r="1.6" fill="currentColor" /><path d="M5 6.6 V13.4 M5 10 C5 10 8 10 15 10" stroke="currentColor" strokeWidth="1.4" fill="none" /></svg>),
// A microphone — the research podcast.
podcast: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="7.5" y="2.5" width="5" height="9" rx="2.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><path d="M4.5 9.5 A5.5 5.5 0 0 0 15.5 9.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><path d="M10 15 V17.5" stroke="currentColor" strokeWidth="1.4" /></svg>),
infra: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="3.5" y="4" width="13" height="5" rx="1.4" stroke="currentColor" strokeWidth="1.4" fill="none" /><rect x="3.5" y="11" width="13" height="5" rx="1.4" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="6.4" cy="6.5" r="1" fill="currentColor" /><circle cx="6.4" cy="13.5" r="1" fill="currentColor" /></svg>), infra: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="3.5" y="4" width="13" height="5" rx="1.4" stroke="currentColor" strokeWidth="1.4" fill="none" /><rect x="3.5" y="11" width="13" height="5" rx="1.4" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="6.4" cy="6.5" r="1" fill="currentColor" /><circle cx="6.4" cy="13.5" r="1" fill="currentColor" /></svg>),
}; };
const TIER_TABS: { key: Tier; label: string }[] = [ const TIER_TABS: { key: Tier; label: string }[] = [
{ key: "world", label: "VIZ" }, { key: "world", label: "VIZ" },
{ key: "missions", label: "MISSIONS" }, { key: "missions", label: "MISSIONS" },
{ key: "claw", label: "AGENT" }, { key: "claw", label: "AGENT" },
{ key: "podcast", label: "PODCAST" },
{ key: "repos", label: "REPOS" }, { key: "repos", label: "REPOS" },
{ key: "infra", label: "INFRA" }, { key: "infra", label: "INFRA" },
]; ];
@@ -618,6 +622,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
isMissions = tier === "missions", isMissions = tier === "missions",
isClaw = tier === "claw", isClaw = tier === "claw",
isRepos = tier === "repos", isRepos = tier === "repos",
isPodcast = tier === "podcast",
isInfra = tier === "infra"; isInfra = tier === "infra";
const [missionsSel, setMissionsSel] = useState<string | null>(null); const [missionsSel, setMissionsSel] = useState<string | null>(null);
const [missionsRefresh, setMissionsRefresh] = useState(0); const [missionsRefresh, setMissionsRefresh] = useState(0);
@@ -953,7 +958,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
</div> </div>
<button type="button" onClick={() => { setSelectMode((v) => { if (v) setSelectedAgents(new Set()); return !v; }); }} title="Select to manage" aria-label="Select to manage" style={{ flex: "none", width: 34, height: 34, borderRadius: 9, border: `1px solid ${selectMode ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.12)"}`, background: selectMode ? "rgba(255,111,97,.12)" : "transparent", color: selectMode ? "#ff6f61" : "#9a9aa2", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><Wrench aria-hidden size={16} /></button> <button type="button" onClick={() => { setSelectMode((v) => { if (v) setSelectedAgents(new Set()); return !v; }); }} title="Select to manage" aria-label="Select to manage" style={{ flex: "none", width: 34, height: 34, borderRadius: 9, border: `1px solid ${selectMode ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.12)"}`, background: selectMode ? "rgba(255,111,97,.12)" : "transparent", color: selectMode ? "#ff6f61" : "#9a9aa2", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><Wrench aria-hidden size={16} /></button>
</div> </div>
) : isInfra || isMissions || isRepos ? null : ( ) : isInfra || isMissions || isRepos || isPodcast ? null : (
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)", display: "flex", alignItems: "center", gap: 8 }}> <div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)", display: "flex", alignItems: "center", gap: 8 }}>
{/* Title on the left; toolbar (wrench / history / brain) sits {/* Title on the left; toolbar (wrench / history / brain) sits
flush right at the same baseline. The prior "N orgs · N co flush right at the same baseline. The prior "N orgs · N co
@@ -1126,6 +1131,8 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
setTier("claw"); setTier("claw");
}} }}
/> />
) : isPodcast ? (
<PodcastPanel />
) : isRepos ? ( ) : isRepos ? (
<RepoCanvas selectedId={repoSel} refreshKey={repoRefresh} /> <RepoCanvas selectedId={repoSel} refreshKey={repoRefresh} />
) : isWorld ? ( ) : isWorld ? (
@@ -89,6 +89,7 @@ const TEMPLATE_LABEL: Record<TemplateKind, string> = {
security_hardening: "Security Hardening", security_hardening: "Security Hardening",
refactor: "Refactor", refactor: "Refactor",
benchmark: "Benchmark", benchmark: "Benchmark",
continuous_research: "Continuous Research",
custom: "Custom", custom: "Custom",
}; };
@@ -76,6 +76,11 @@ export function MissionWizard({
const [step, setStep] = useState<Step>(1); const [step, setStep] = useState<Step>(1);
const [templateKind, setTemplateKind] = useState<TemplateKind>("research_only"); const [templateKind, setTemplateKind] = useState<TemplateKind>("research_only");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
// Continuous Research only: the arXiv topics this loop tracks. Without it the
// backend falls back to `library::default_topics()` — a hardcoded list that is
// somebody else's research interests, so every mission created from the UI
// produced the same podcast whatever the operator actually wanted followed.
const [topics, setTopics] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [repo, setRepo] = useState<PickedRepo | null>(null); const [repo, setRepo] = useState<PickedRepo | null>(null);
const [researchTeamIds, setResearchTeamIds] = useState<Set<string>>(new Set()); const [researchTeamIds, setResearchTeamIds] = useState<Set<string>>(new Set());
@@ -199,6 +204,7 @@ export function MissionWizard({
() => templates.find((p) => p.kind === templateKind) ?? templates[0], () => templates.find((p) => p.kind === templateKind) ?? templates[0],
[templates, templateKind], [templates, templateKind],
); );
const isContinuousResearch = templateKind === "continuous_research";
// Which team panels Advanced shows depends on the phases the picked workflow // Which team panels Advanced shows depends on the phases the picked workflow
// includes. A benchmark-only mission needs neither; research_and_code needs // includes. A benchmark-only mission needs neither; research_and_code needs
@@ -329,6 +335,11 @@ export function MissionWizard({
}; };
}); });
const topicList = topics
.split("\n")
.map((t) => t.trim())
.filter(Boolean);
const created = await createMission({ const created = await createMission({
title: title.trim(), title: title.trim(),
template_kind: templateKind, template_kind: templateKind,
@@ -340,7 +351,14 @@ export function MissionWizard({
target_node_id: target_node_id:
runtimeKind === "local_herdr" ? targetNodeId : undefined, runtimeKind === "local_herdr" ? targetNodeId : undefined,
backend: runtimeKind === "microvm" ? backend : undefined, backend: runtimeKind === "microvm" ? backend : undefined,
config: { phase_teams }, config: {
phase_teams,
// Only for the loop that reads it; an empty list means "use the
// defaults" rather than "harvest nothing".
...(isContinuousResearch && topicList.length > 0
? { topics: topicList }
: {}),
},
}); });
onCreated(created.id); onCreated(created.id);
} catch (e) { } catch (e) {
@@ -571,6 +589,34 @@ export function MissionWizard({
<p style={{ ...hintStyle, color: "#ff8a7a" }}>{error}</p> <p style={{ ...hintStyle, color: "#ff8a7a" }}>{error}</p>
)} )}
{isContinuousResearch && (
<p style={{ ...hintStyle, marginTop: 6 }}>
For this loop the description IS the brief the agents judge
against — name the projects you are working on and what each
one needs. A vague brief produces a vague episode.
</p>
)}
{isContinuousResearch && (
<>
<span style={labelStyle}>Topics to follow</span>
<textarea
id="mission-topics"
value={topics}
onChange={(e) => setTopics(e.target.value)}
rows={4}
placeholder={"approximate nearest neighbor search\nagent memory retrieval\nLLM as a judge evaluation"}
style={{ ...fieldStyle, resize: "vertical", fontFamily: "inherit" }}
/>
<p style={hintStyle}>
One arXiv search per line. Plain words are fine — they are
matched as a phrase, and broadened automatically if that finds
nothing. Leave blank to use the defaults. Papers already
covered are never offered twice.
</p>
</>
)}
{preset.requiresRepo ? ( {preset.requiresRepo ? (
<> <>
<span style={labelStyle}>Repository</span> <span style={labelStyle}>Repository</span>
@@ -35,6 +35,7 @@ const TEMPLATE_BADGE: Record<TemplateKind, { label: string; color: string }> = {
security_hardening: { label: "SECURITY", color: "#ff8a7a" }, security_hardening: { label: "SECURITY", color: "#ff8a7a" },
refactor: { label: "REFACTOR", color: "#ffb44a" }, refactor: { label: "REFACTOR", color: "#ffb44a" },
benchmark: { label: "BENCHMARK", color: "#83e6a5" }, benchmark: { label: "BENCHMARK", color: "#83e6a5" },
continuous_research: { label: "PODCAST", color: "#e0b0ff" },
custom: { label: "CUSTOM", color: "#8a8a92" }, custom: { label: "CUSTOM", color: "#8a8a92" },
}; };
@@ -0,0 +1,190 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { listEpisodes, subscription, episodeSrc, type Episode } from "@/lib/api/podcast";
/// The podcast tier.
///
/// Two jobs, in this order: hand over the feed URL so a podcast app can take
/// over, and list what has been produced. The player here is for checking an
/// episode at a desk — the feed is how it is actually listened to, because a
/// browser tab does not download overnight, play offline or survive a lock
/// screen.
export function PodcastPanel() {
const [eps, setEps] = useState<Episode[]>([]);
const [unrenderable, setUnrenderable] = useState(0);
const [feed, setFeed] = useState<string | null>(null);
const [reachable, setReachable] = useState(true);
const [playing, setPlaying] = useState<{ id: string; url: string } | null>(null);
const [copied, setCopied] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const [list, sub] = await Promise.all([listEpisodes(), subscription()]);
setEps(list.episodes);
setUnrenderable(list.unrenderable);
setFeed(sub.feedUrl);
setReachable(sub.reachable);
setErr(null);
} catch (e) {
setErr(e instanceof Error ? e.message : "could not load episodes");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
// The render sweep runs every two minutes; refresh on that cadence so a new
// episode appears without the operator reloading the page.
const t = setInterval(() => void load(), 120_000);
return () => clearInterval(t);
}, [load]);
const play = (id: string) => setPlaying({ id, url: episodeSrc(id) });
const copyFeed = async () => {
if (!feed) return;
await navigator.clipboard.writeText(feed);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div style={{ padding: "24px 28px", overflowY: "auto", height: "100%" }}>
<h2 style={{ margin: 0, fontSize: 15, letterSpacing: ".08em", color: "#e8e8ea" }}>
RESEARCH PODCAST
</h2>
<p style={{ margin: "6px 0 20px", fontSize: 12.5, color: "#8a8a92", maxWidth: 620 }}>
Papers read against your projects, voiced each morning. Subscribe once and
episodes download overnight.
</p>
{/* Subscribe — the point of the screen. */}
<div style={panel}>
<div style={{ fontSize: 11, letterSpacing: ".08em", color: "#8a8a92", marginBottom: 8 }}>
SUBSCRIBE
</div>
{feed ? (
<>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<code style={urlBox}>{feed.replace(/token=[^&]+/, "token=…")}</code>
<button onClick={copyFeed} style={btn}>
{copied ? "copied" : "copy"}
</button>
</div>
{!reachable && (
<p style={{ margin: "10px 0 0", fontSize: 11.5, color: "#c9a227", lineHeight: 1.5 }}>
This address is local to the server, so a phone cannot reach it.
Set CLAWMATES_PUBLIC_URL to a hostname your devices can resolve
before subscribing.
</p>
)}
<p style={{ margin: "10px 0 0", fontSize: 11.5, color: "#7a7a82", lineHeight: 1.5 }}>
Paste into Apple Podcasts (File → Follow a Show by URL), Overcast or
Pocket Casts. The link carries your session token, so treat it like a
password — anyone with it can read this feed, and revoking your session
revokes it.
</p>
</>
) : (
<span style={{ fontSize: 12, color: "#8a8a92" }}>no session</span>
)}
</div>
{err && <div style={{ ...panel, color: "#e0796f" }}>{err}</div>}
{unrenderable > 0 && (
<div style={{ ...panel, borderColor: "#4a3a2a" }}>
<span style={{ fontSize: 12, color: "#c9a227" }}>
{unrenderable} mission{unrenderable === 1 ? "" : "s"} produced no audio —
the script was cleaned up before the renderer reached it. The text is
still on that mission&apos;s vault branch.
</span>
</div>
)}
<div style={{ fontSize: 11, letterSpacing: ".08em", color: "#8a8a92", margin: "22px 0 10px" }}>
EPISODES
</div>
{loading ? (
<div style={{ fontSize: 12, color: "#8a8a92" }}>loading…</div>
) : eps.length === 0 ? (
<div style={{ fontSize: 12.5, color: "#8a8a92", lineHeight: 1.6 }}>
No episodes yet. Launch a <strong style={{ color: "#c8c8cc" }}>Continuous
Research</strong> mission from Missions — it harvests new papers, reads
them against the projects in your mission description, and the episode is
rendered a few minutes after the mission finishes.
</div>
) : (
eps.map((e) => (
<div key={e.id} style={row}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: "#e8e8ea", marginBottom: 3 }}>{e.title}</div>
<div style={{ fontSize: 11, color: "#7a7a82" }}>
{e.date} · {fmtDur(e.durationSecs)} · {(e.bytes / 1_048_576).toFixed(1)} MB ·{" "}
{e.renderedBy}
</div>
</div>
<button onClick={() => play(e.id)} style={btn}>
{playing?.id === e.id ? "playing" : "play"}
</button>
</div>
))
)}
{playing && (
// eslint-disable-next-line jsx-a11y/media-has-caption
<audio src={playing.url} controls autoPlay style={{ width: "100%", marginTop: 16 }} />
)}
</div>
);
}
function fmtDur(s: number): string {
const m = Math.floor(s / 60);
return `${m}m ${String(s % 60).padStart(2, "0")}s`;
}
const panel: React.CSSProperties = {
border: "1px solid #2a2a30",
borderRadius: 8,
padding: "14px 16px",
marginBottom: 12,
background: "#141418",
maxWidth: 720,
};
const row: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 12,
padding: "11px 14px",
border: "1px solid #24242a",
borderRadius: 8,
marginBottom: 8,
maxWidth: 720,
};
const btn: React.CSSProperties = {
background: "#1e1e24",
border: "1px solid #33333a",
color: "#c8c8cc",
borderRadius: 6,
padding: "6px 12px",
fontSize: 11.5,
cursor: "pointer",
};
const urlBox: React.CSSProperties = {
flex: 1,
fontSize: 11,
color: "#9a9aa2",
background: "#0e0e12",
border: "1px solid #24242a",
borderRadius: 6,
padding: "7px 10px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
+1
View File
@@ -8,6 +8,7 @@ export type TemplateKind =
| "security_hardening" | "security_hardening"
| "refactor" | "refactor"
| "benchmark" | "benchmark"
| "continuous_research"
| "custom"; | "custom";
export type MissionStatus = export type MissionStatus =
+46
View File
@@ -0,0 +1,46 @@
// Client-side only. Requests go through the same-origin /api proxy, which
// swaps the httpOnly session cookie for a bearer — so nothing here may import
// `resolveBearer`, which is server-only (`next/headers`) and would drag
// next/headers into the browser bundle.
export interface Episode {
id: string;
missionId: string;
missionTitle: string;
title: string;
date: string;
bytes: number;
durationSecs: number;
renderedBy: string;
createdAt: number;
}
export interface EpisodeList {
episodes: Episode[];
/// Missions whose script was cleaned up before the renderer reached them.
/// Surfaced so a missing day reads as a known gap rather than silence.
unrenderable: number;
}
export async function listEpisodes(): Promise<EpisodeList> {
const res = await fetch("/api/podcast/episodes", { cache: "no-store" });
if (!res.ok) throw new Error(`episodes: ${res.status}`);
return res.json();
}
export interface Subscription {
feedUrl: string;
/// False when the backend still advertises localhost — a phone cannot reach
/// that, and a feed that only works on this machine never syncs.
reachable: boolean;
}
export async function subscription(): Promise<Subscription> {
const res = await fetch("/api/podcast/subscription", { cache: "no-store" });
if (!res.ok) throw new Error(`subscription: ${res.status}`);
return res.json();
}
/// Playing in the browser goes through the proxy, so no token is needed here —
/// only the feed, which a podcast app fetches directly, carries one.
export const episodeSrc = (id: string) => `/api/podcast/episodes/${id}.mp3`;
+7 -1
View File
@@ -48,7 +48,13 @@ export default async function proxy(
process.env.LOCAL_AUTOLOGIN_EMAIL && process.env.LOCAL_AUTOLOGIN_EMAIL &&
process.env.LOCAL_AUTOLOGIN_PASSWORD && process.env.LOCAL_AUTOLOGIN_PASSWORD &&
path !== "/auth/autologin" && path !== "/auth/autologin" &&
path !== "/login" path !== "/login" &&
// The podcast feed and its audio authenticate by `?token=`, and are
// fetched by a podcast app that has no cookie and follows redirects
// blindly. Sent to autologin it would store an HTML page as the episode.
// The backend still validates the token, so this exempts the redirect,
// not the auth.
!path.startsWith("/api/podcast/")
) { ) {
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
url.pathname = "/auth/autologin"; url.pathname = "/auth/autologin";