activity: follow the agent's background work live + chat-event retention

"Activity" (placeholder name — jobs/tasks were taken) = watch background tasks
scroll in parallel with chat. Built DB-free; NOT restarted — deploy + test in
the morning.

- NDJSON progress contract (activity/progress.ts): capabilities append
  {job,cap,phase,status,pct,detail,ts,...} lines; tolerant parser treats any
  JSON object with phase/status as structured progress, else a raw log line.
- Backend (activity/router.ts, owner-only, path-guarded):
  - GET /api/activity/tasks — registry by scanning /tmp/claude-*/<cwd>/tasks/
    *.output (harness run_in_background) + announced detached jobs.
  - POST /api/activity/announce {name,path} — register a detached (setsid) job's
    log so it's followable too (the setsid case is on the critical path, since
    the warm worker now makes plain run_in_background the default for heavy jobs).
  - GET /api/activity/stream?task=<id>|path=<abs> — SSE tail (poll + offset),
    emitting {kind:'line'|'progress'} with NDJSON parsed.
- Frontend /activity screen + Radio nav item: task list (active dot) → live tail
  with a phase/pct progress header + raw log, following the /system-monitor pattern.
- Retention: startChatEventRetention() prunes chat_session_events >7d every 6h
  (wired in bootstrap) so the durable queue stays bounded.

Verified headlessly (no restart): parseTailLine classification, and the scan
finds 53 real task output files. Endpoints + UI untested until deploy.

Deferred (see handoff): cross-device sync + OpenCode parity (both touch the
now-stable chat path — won't ship un-restart-tested); task:progress into chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 00:56:10 +00:00
co-authored by Claude Opus 4.8
parent 6b3eb247a3
commit 35973a5505
10 changed files with 382 additions and 0 deletions
@@ -0,0 +1,120 @@
import { useEffect, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { Radio, FileText, Activity as ActivityIcon } from 'lucide-react';
type TaskRow = { id: string; source: 'harness'; cwd: string; sizeBytes: number; updatedAt: number; active: boolean };
type DetachedRow = { id: string; source: 'detached'; path: string; announcedAt: number };
type Registry = { tasks: TaskRow[]; detached: DetachedRow[] };
type ProgressLine = { phase?: string; status?: string; pct?: number; detail?: string; cap?: string; job?: string };
const POLL_MS = 3000;
const MAX_LINES = 600;
export const ActivityScreen = () => {
const { token, get } = useClient();
const [reg, setReg] = useState<Registry>({ tasks: [], detached: [] });
const [selected, setSelected] = useState<{ label: string; query: string } | null>(null);
const [lines, setLines] = useState<string[]>([]);
const [progress, setProgress] = useState<ProgressLine | null>(null);
const esRef = useRef<EventSource | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
// Poll the registry (harness task files + announced detached jobs).
useEffect(() => {
let alive = true;
const tick = () => get<Registry>('/activity/tasks').then((r) => alive && setReg(r)).catch(() => {});
tick();
const iv = setInterval(tick, POLL_MS);
return () => { alive = false; clearInterval(iv); };
}, []);
// Live-tail the selected task via SSE (EventSource can't set headers → token in the query string).
useEffect(() => {
esRef.current?.close();
setLines([]);
setProgress(null);
if (!selected) return;
const es = new EventSource(`/api/activity/stream?${selected.query}&token=${encodeURIComponent(token ?? '')}`);
esRef.current = es;
es.onmessage = (ev) => {
try {
const d = JSON.parse(ev.data) as { kind: string; text?: string; progress?: ProgressLine };
if (d.kind === 'progress' && d.progress) setProgress(d.progress);
else if (d.kind === 'line' && typeof d.text === 'string') setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), d.text!]);
} catch { /* ignore */ }
};
return () => es.close();
}, [selected, token]);
useEffect(() => { scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight); }, [lines]);
const rowCls = (active: boolean) =>
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm ${active ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'}`;
return (
<div className="flex h-full w-full">
<aside className="flex w-72 shrink-0 flex-col overflow-y-auto border-r border-border p-3">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<ActivityIcon size={16} className="text-primary" /> Activity
</div>
<div className="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Background tasks</div>
{reg.tasks.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">none running</p>}
{reg.tasks.map((t) => (
<button key={t.id} type="button" onClick={() => setSelected({ label: t.id, query: `task=${encodeURIComponent(t.id)}` })} className={rowCls(selected?.label === t.id)} title={t.cwd}>
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${t.active ? 'bg-emerald-500 animate-pulse' : 'bg-muted-foreground/40'}`} />
<span className="truncate font-mono text-xs">{t.id}</span>
</button>
))}
{reg.detached.length > 0 && (
<div className="mb-1 mt-4 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Detached</div>
)}
{reg.detached.map((d) => (
<button key={d.id} type="button" onClick={() => setSelected({ label: d.id, query: `path=${encodeURIComponent(d.path)}` })} className={rowCls(selected?.label === d.id)} title={d.path}>
<FileText size={13} className="shrink-0" />
<span className="truncate">{d.id}</span>
</button>
))}
</aside>
<main className="flex min-w-0 flex-1 flex-col">
{selected ? (
<>
<div className="shrink-0 border-b border-border p-3">
<div className="flex items-center gap-2 text-sm text-foreground">
<Radio size={14} className="text-primary" />
<span className="truncate font-mono">{selected.label}</span>
</div>
{progress && (
<div className="mt-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="truncate">
{[progress.cap, progress.phase].filter(Boolean).join(' · ')}
{progress.status ? ` (${progress.status})` : ''}
</span>
<span className="shrink-0 pl-2">{progress.detail ?? (typeof progress.pct === 'number' ? `${progress.pct}%` : '')}</span>
</div>
{typeof progress.pct === 'number' && (
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${Math.min(100, Math.max(0, progress.pct))}%` }} />
</div>
)}
</div>
)}
</div>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto bg-black/30 p-3 font-mono text-xs text-foreground/80">
{lines.length === 0 ? (
<span className="text-muted-foreground">waiting for output</span>
) : (
lines.map((l, i) => <div key={i} className="whitespace-pre-wrap break-words">{l}</div>)
)}
</div>
</>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">Select a task to follow its live output</div>
)}
</main>
</div>
);
};