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
+39
View File
@@ -0,0 +1,39 @@
// Shared structured-progress contract for the Activity feature.
//
// Any capability that wants live progress bars / phase chips (instead of the UI regexing prose) appends
// NDJSON lines of this shape to its output/log file, alongside whatever human text it already writes:
//
// {"job":"dearly-devoted","cap":"split-audiobook","phase":"transcription","status":"running","pct":9,"detail":"0:50:00 / 9:28:24","ts":1730000000000}
// {"job":"dearly-devoted","cap":"split-audiobook","phase":"verdict","status":"complete","verdict":"CLEAN","chapters":31,"pieces":52,"ts":...}
//
// The parser is deliberately tolerant: a line is treated as structured progress iff it JSON-parses to
// an object carrying a `phase` or `status`; everything else is passed through as a raw log line. So a
// capability can dual-write (human lines + NDJSON) and both render correctly.
export type ProgressLine = {
job?: string;
cap?: string;
phase?: string;
status?: 'running' | 'complete' | 'failed' | string;
pct?: number;
detail?: string;
ts?: number;
[k: string]: unknown;
};
export type TailEvent = { kind: 'progress'; progress: ProgressLine } | { kind: 'line'; text: string };
export function parseTailLine(line: string): TailEvent {
const trimmed = line.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
try {
const obj = JSON.parse(trimmed) as Record<string, unknown>;
if (obj && (typeof obj.phase === 'string' || typeof obj.status === 'string')) {
return { kind: 'progress', progress: obj as ProgressLine };
}
} catch {
/* not JSON → fall through to raw line */
}
}
return { kind: 'line', text: line };
}
+191
View File
@@ -0,0 +1,191 @@
import { readdir, stat, readFile, writeFile, mkdir, realpath, open } from 'node:fs/promises';
import { join, dirname, resolve as resolvePath } from 'node:path';
import { createRouter } from '../../create-router';
import { parseTailLine } from './progress';
// "Activity" — follow the agent's background work live. Two producers, one primitive (tail a file):
// - Harness `run_in_background` tasks write /tmp/claude-<uid>/<encoded-cwd>/tasks/<task_id>.output
// (append-only, live). Discovered by scanning; the FILE is the source of truth.
// - Detached (setsid) jobs write an arbitrary log; they self-register via POST /announce with the log
// path, so they're followable too (the setsid case is on the critical path, not a footnote).
// Owner-only (the account gate confines non-owners to /auth+/music). Paths are traversal-guarded.
export const activityRouter = createRouter();
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
const HOME_DIR = process.env.HOME_DIR ?? process.env.HOME ?? '';
const ANNOUNCED_PATH = join(DATA_PATH, 'activity', 'announced.json');
const ALLOWED_ROOTS = ['/tmp', DATA_PATH, HOME_DIR].filter(Boolean);
const ACTIVE_WINDOW_MS = 120_000; // a task file touched within this is considered "active"
type TaskFile = { taskId: string; path: string; cwdLabel: string; sizeBytes: number; mtimeMs: number };
type Announced = { name: string; path: string; ts: number };
// Scan the claude tmp tree for harness background-task output files (…/tasks/<task_id>.output).
async function listTaskFiles(): Promise<TaskFile[]> {
const out: TaskFile[] = [];
let tmp: string[];
try {
tmp = await readdir('/tmp');
} catch {
return out;
}
for (const name of tmp) {
if (!name.startsWith('claude-')) continue;
const base = join('/tmp', name);
let cwdDirs: import('node:fs').Dirent[];
try {
cwdDirs = await readdir(base, { withFileTypes: true });
} catch {
continue;
}
for (const cwd of cwdDirs) {
if (!cwd.isDirectory()) continue;
const tasksDir = join(base, cwd.name, 'tasks');
let files: string[];
try {
files = await readdir(tasksDir);
} catch {
continue;
}
for (const f of files) {
if (!f.endsWith('.output')) continue;
const p = join(tasksDir, f);
const st = await stat(p).catch(() => null);
if (!st) continue;
out.push({ taskId: f.slice(0, -'.output'.length), path: p, cwdLabel: cwd.name, sizeBytes: st.size, mtimeMs: st.mtimeMs });
}
}
}
return out;
}
async function readAnnounced(): Promise<Announced[]> {
try {
return JSON.parse(await readFile(ANNOUNCED_PATH, 'utf8')) as Announced[];
} catch {
return [];
}
}
/** Resolve a path only if it lands under an allowed root (guards traversal); the file need not exist yet. */
async function resolveAllowed(p: string): Promise<string | null> {
const under = (abs: string) => ALLOWED_ROOTS.some((root) => abs === root || abs.startsWith(root + '/'));
try {
const real = await realpath(p);
return under(real) ? real : null;
} catch {
const abs = resolvePath(p);
return under(abs) ? abs : null;
}
}
// GET /tasks — registry of followable background work.
activityRouter.get('/tasks', async (ctx) => {
const [harness, announced] = await Promise.all([listTaskFiles(), readAnnounced()]);
const now = Date.now();
return ctx.json({
tasks: harness
.sort((a, b) => b.mtimeMs - a.mtimeMs)
.map((t) => ({
id: t.taskId,
source: 'harness' as const,
cwd: t.cwdLabel,
sizeBytes: t.sizeBytes,
updatedAt: Math.round(t.mtimeMs),
active: now - t.mtimeMs < ACTIVE_WINDOW_MS,
})),
detached: announced.map((a) => ({ id: a.name, source: 'detached' as const, path: a.path, announcedAt: a.ts })),
});
});
// POST /announce { name, path } — register a detached (setsid) job's log so it's followable too.
activityRouter.post('/announce', async (ctx) => {
const body = ctx.get('body') as { name?: string; path?: string } | undefined;
if (!body?.name || !body?.path) return ctx.text('name and path are required', 400);
const safe = await resolveAllowed(body.path);
if (!safe) return ctx.text('path is not under an allowed root', 403);
await mkdir(dirname(ANNOUNCED_PATH), { recursive: true });
const list = await readAnnounced();
const next = [{ name: body.name, path: safe, ts: Date.now() }, ...list.filter((a) => a.name !== body.name)].slice(0, 100);
await writeFile(ANNOUNCED_PATH, JSON.stringify(next));
return ctx.json({ ok: true, name: body.name, path: safe });
});
// GET /stream?task=<id> | ?path=<abs> — SSE that tails the output file, emitting {kind:'line'|'progress'}.
activityRouter.get('/stream', async (ctx) => {
const task = ctx.req.query('task');
const pathQ = ctx.req.query('path');
let target: string | null = null;
if (task) {
target = (await listTaskFiles()).find((f) => f.taskId === task)?.path ?? null;
} else if (pathQ) {
target = await resolveAllowed(pathQ);
}
if (!target) return ctx.text('a valid task id or path is required', 400);
const filePath = target;
const enc = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
let offset = 0;
let closed = false;
const send = (obj: unknown) => {
try {
controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
} catch {
/* controller closed */
}
};
const drain = async () => {
try {
const st = await stat(filePath);
if (st.size < offset) offset = 0; // truncated / rotated
if (st.size > offset) {
const fh = await open(filePath, 'r');
const buf = Buffer.alloc(st.size - offset);
await fh.read(buf, 0, buf.length, offset);
await fh.close();
offset = st.size;
for (const line of buf.toString('utf8').split('\n')) {
if (line !== '') send(parseTailLine(line));
}
}
} catch {
/* file may not exist yet — keep polling */
}
};
await drain();
const poll = setInterval(drain, 800);
const heartbeat = setInterval(() => {
try {
controller.enqueue(enc.encode(': hb\n\n'));
} catch {
/* closed */
}
}, 15_000);
const cleanup = () => {
if (closed) return;
closed = true;
clearInterval(poll);
clearInterval(heartbeat);
try {
controller.close();
} catch {
/* already closed */
}
};
ctx.req.raw.signal.addEventListener('abort', cleanup);
setTimeout(cleanup, 60 * 60 * 1000); // safety cap
},
});
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' },
});
});