From d379835a58f0abf5c0755ad521341e34eefddf16 Mon Sep 17 00:00:00 2001 From: Evgeny Pavlov Date: Thu, 17 Sep 2026 15:36:53 -0700 Subject: [PATCH 1/5] hackbot-runtime: publish the Claude Code session transcripts as run artifacts --- .../hackbot_runtime/runtime.py | 40 +++++++++++++++++++ libs/hackbot-runtime/tests/test_runtime.py | 34 ++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/libs/hackbot-runtime/hackbot_runtime/runtime.py b/libs/hackbot-runtime/hackbot_runtime/runtime.py index 30af26eea0..7dec6d22b2 100644 --- a/libs/hackbot-runtime/hackbot_runtime/runtime.py +++ b/libs/hackbot-runtime/hackbot_runtime/runtime.py @@ -1,6 +1,7 @@ import asyncio import inspect import logging +import os import sys import traceback from collections.abc import Awaitable, Callable @@ -32,6 +33,11 @@ _CONFIG_NAME = "hackbot.toml" _SUMMARY_NAME = "summary.json" _AGENT_LOG_KEY = "logs/agent.log" +_TRANSCRIPTS_PREFIX = "transcripts/" +_TRANSCRIPT_CONTENT_TYPES = { + ".jsonl": "application/x-ndjson", + ".json": "application/json", +} def _configure_auth() -> None: @@ -135,6 +141,35 @@ def _publish_log(ctx: HackbotContext) -> None: ctx.publish_file(_AGENT_LOG_KEY, ctx.log_path, "text/plain; charset=utf-8") +def _claude_projects_dir() -> Path: + config_dir = os.environ.get("CLAUDE_CONFIG_DIR") + return (Path(config_dir) if config_dir else Path.home() / ".claude") / "projects" + + +def _publish_transcripts(ctx: HackbotContext) -> None: + """Publish the Claude Code session transcripts written during the run. + + The CLI the SDK spawns keeps every session under ``~/.claude/projects``, with + subagent transcripts beside it in ``/subagents/``. The container is + fresh per execution, so all of them belong to this run. + """ + projects = _claude_projects_dir() + if not projects.is_dir(): + return + for session_file in sorted(projects.glob("*/*.jsonl")): + session = session_file.stem + _publish_transcript(ctx, f"{_TRANSCRIPTS_PREFIX}{session}.jsonl", session_file) + subagents = session_file.with_suffix("") / "subagents" + for path in sorted(subagents.glob("*")) if subagents.is_dir() else (): + if path.is_file(): + key = f"{_TRANSCRIPTS_PREFIX}{session}/subagents/{path.name}" + _publish_transcript(ctx, key, path) + + +def _publish_transcript(ctx: HackbotContext, key: str, path: Path) -> None: + ctx.publish_file(key, path, _TRANSCRIPT_CONTENT_TYPES.get(path.suffix)) + + def _finish(ctx: HackbotContext, outcome: object) -> int: """Write summary.json from the agent's outcome and return the exit code. @@ -163,6 +198,11 @@ def _finish(ctx: HackbotContext, outcome: object) -> int: except Exception: log.exception("Failed to publish agent log") + try: + _publish_transcripts(ctx) + except Exception: + log.exception("Failed to publish Claude Code transcripts") + try: ctx.publish_changes() except Exception: diff --git a/libs/hackbot-runtime/tests/test_runtime.py b/libs/hackbot-runtime/tests/test_runtime.py index 4a638a79a0..8ab742cc21 100644 --- a/libs/hackbot-runtime/tests/test_runtime.py +++ b/libs/hackbot-runtime/tests/test_runtime.py @@ -135,6 +135,40 @@ def test_finish_skips_log_when_none_written(tmp_path): assert not (tmp_path / "artifacts" / "local-test" / "logs" / "agent.log").exists() +def test_finish_publishes_claude_transcripts(tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "claude")) + project = tmp_path / "claude" / "projects" / "-tmp-repo" + subagents = project / "sess-1" / "subagents" + subagents.mkdir(parents=True) + (project / "sess-1.jsonl").write_text('{"type":"user"}\n') + (project / "sess-2.jsonl").write_text('{"type":"assistant"}\n') + (subagents / "agent-abc.jsonl").write_text('{"type":"user"}\n') + (subagents / "agent-abc.meta.json").write_text('{"agentType":"Explore"}') + ctx = _ctx(tmp_path) + + _finish(ctx, HackbotAgentResult(num_turns=1)) + + transcripts = tmp_path / "artifacts" / "local-test" / "transcripts" + assert sorted( + str(p.relative_to(transcripts)) for p in transcripts.rglob("*") if p.is_file() + ) == [ + "sess-1.jsonl", + "sess-1/subagents/agent-abc.jsonl", + "sess-1/subagents/agent-abc.meta.json", + "sess-2.jsonl", + ] + assert (transcripts / "sess-2.jsonl").read_text() == '{"type":"assistant"}\n' + + +def test_finish_skips_transcripts_without_projects_dir(tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "claude")) + ctx = _ctx(tmp_path) + + _finish(ctx, HackbotAgentResult(num_turns=1)) + + assert not (tmp_path / "artifacts" / "local-test" / "transcripts").exists() + + def test_runs_are_namespaced_by_run_id(tmp_path): ctx_a = _ctx(tmp_path, run_id="run-a") ctx_b = _ctx(tmp_path, run_id="run-b") From f96d565b417035de54890abfc4a68f5cd8492f18 Mon Sep 17 00:00:00 2001 From: Evgeny Pavlov Date: Thu, 17 Sep 2026 15:36:55 -0700 Subject: [PATCH 2/5] hackbot-ui: build a Firefox Profiler profile from a run's transcripts --- .../app/api/runs/[runId]/profile/route.ts | 31 ++++++++ services/hackbot-ui/claude-profiler.d.ts | 11 +++ services/hackbot-ui/lib/profile.ts | 37 +++++++++ services/hackbot-ui/lib/transcripts.test.ts | 64 +++++++++++++++ services/hackbot-ui/lib/transcripts.ts | 78 +++++++++++++++++++ services/hackbot-ui/next.config.mjs | 2 + services/hackbot-ui/package-lock.json | 10 +++ services/hackbot-ui/package.json | 1 + 8 files changed, 234 insertions(+) create mode 100644 services/hackbot-ui/app/api/runs/[runId]/profile/route.ts create mode 100644 services/hackbot-ui/claude-profiler.d.ts create mode 100644 services/hackbot-ui/lib/profile.ts create mode 100644 services/hackbot-ui/lib/transcripts.test.ts create mode 100644 services/hackbot-ui/lib/transcripts.ts diff --git a/services/hackbot-ui/app/api/runs/[runId]/profile/route.ts b/services/hackbot-ui/app/api/runs/[runId]/profile/route.ts new file mode 100644 index 0000000000..9536d7c05d --- /dev/null +++ b/services/hackbot-ui/app/api/runs/[runId]/profile/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; + +import { apiErrorResponse } from "@/lib/api-errors"; +import { getRun } from "@/lib/hackbot"; +import { buildProfile } from "@/lib/profile"; +import { getAuthedEmail } from "@/lib/session"; + +export const dynamic = "force-dynamic"; + +// GET /api/runs/:runId/profile — the run's Claude Code transcripts as a Firefox +// Profiler profile, built on demand from the transcripts/ artifacts. +export async function GET( + _req: Request, + { params }: { params: Promise<{ runId: string }> } +) { + if (!(await getAuthedEmail())) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { runId } = await params; + try { + const profile = await buildProfile(await getRun(runId)); + // Transcripts are only listed once the run is finalized, so the profile is + // stable from then on. + return NextResponse.json(profile, { + headers: { "Cache-Control": "private, max-age=3600" }, + }); + } catch (err) { + return apiErrorResponse(err); + } +} diff --git a/services/hackbot-ui/claude-profiler.d.ts b/services/hackbot-ui/claude-profiler.d.ts new file mode 100644 index 0000000000..72e05278df --- /dev/null +++ b/services/hackbot-ui/claude-profiler.d.ts @@ -0,0 +1,11 @@ +declare module "claude-profiler" { + export interface Subagent { + id: string; + meta: object; + entries: unknown[]; + } + export function createFirefoxProfile( + entries: unknown[], + subagents: Subagent[] + ): object; +} diff --git a/services/hackbot-ui/lib/profile.ts b/services/hackbot-ui/lib/profile.ts new file mode 100644 index 0000000000..bd9faf80e5 --- /dev/null +++ b/services/hackbot-ui/lib/profile.ts @@ -0,0 +1,37 @@ +import "server-only"; + +import { createFirefoxProfile } from "claude-profiler"; + +import { getArtifactDownloadUrl, HackbotError } from "./hackbot"; +import { mergeSessions, parseJsonl, transcriptArtifacts } from "./transcripts"; +import type { RunDoc } from "./types"; + +async function fetchArtifact(runId: string, name: string): Promise { + const { url } = await getArtifactDownloadUrl(runId, name); + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) { + throw new HackbotError(`Could not download ${name} (${res.status})`, 502); + } + return res.text(); +} + +// Build a Firefox Profiler profile from the run's Claude Code transcripts. +export async function buildProfile(run: RunDoc): Promise { + const { sessions, subagents } = transcriptArtifacts(run.artifacts); + if (sessions.length === 0) { + throw new HackbotError("Run has no transcripts", 404); + } + const [texts, agents] = await Promise.all([ + Promise.all(sessions.map((name) => fetchArtifact(run.run_id, name))), + Promise.all( + subagents.map(async (s) => ({ + id: s.id, + meta: s.meta + ? (JSON.parse(await fetchArtifact(run.run_id, s.meta)) as object) + : {}, + entries: parseJsonl(await fetchArtifact(run.run_id, s.jsonl)), + })) + ), + ]); + return createFirefoxProfile(mergeSessions(texts.map(parseJsonl)), agents); +} diff --git a/services/hackbot-ui/lib/transcripts.test.ts b/services/hackbot-ui/lib/transcripts.test.ts new file mode 100644 index 0000000000..787bd60e16 --- /dev/null +++ b/services/hackbot-ui/lib/transcripts.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + hasTranscripts, + mergeSessions, + parseJsonl, + transcriptArtifacts, +} from "./transcripts.ts"; + +const artifact = (name: string) => ({ name, size: 1, content_type: null }); + +test("groups session and subagent transcripts, ignoring other artifacts", () => { + const files = transcriptArtifacts( + [ + "summary.json", + "logs/agent.log", + "transcripts/s2.jsonl", + "transcripts/s1.jsonl", + "transcripts/s1/subagents/agent-a1.meta.json", + "transcripts/s1/subagents/agent-a1.jsonl", + "transcripts/s1/subagents/agent-orphan.meta.json", + ].map(artifact) + ); + assert.deepEqual(files, { + sessions: ["transcripts/s1.jsonl", "transcripts/s2.jsonl"], + subagents: [ + { + id: "a1", + jsonl: "transcripts/s1/subagents/agent-a1.jsonl", + meta: "transcripts/s1/subagents/agent-a1.meta.json", + }, + ], + }); +}); + +test("hasTranscripts needs a session file, not just subagents", () => { + assert.equal(hasTranscripts([artifact("summary.json")]), false); + assert.equal( + hasTranscripts([artifact("transcripts/s1/subagents/agent-a1.jsonl")]), + false + ); + assert.equal(hasTranscripts([artifact("transcripts/s1.jsonl")]), true); +}); + +test("parses JSONL and tolerates blank lines", () => { + assert.deepEqual(parseJsonl('{"a":1}\n\n{"a":2}\n'), [{ a: 1 }, { a: 2 }]); +}); + +test("merges sessions in start order without interleaving", () => { + const fix = [ + { timestamp: "2026-09-17T10:05:00Z", n: 1 }, + { timestamp: "2026-09-17T10:06:00Z", n: 2 }, + ]; + const analysis = [ + { n: 0 }, + { timestamp: "2026-09-17T10:00:00Z", n: 3 }, + { timestamp: "2026-09-17T10:07:00Z", n: 4 }, + ]; + assert.deepEqual( + mergeSessions([fix, analysis]).map((e) => e.n), + [0, 3, 4, 1, 2] + ); +}); diff --git a/services/hackbot-ui/lib/transcripts.ts b/services/hackbot-ui/lib/transcripts.ts new file mode 100644 index 0000000000..48a3f67dc2 --- /dev/null +++ b/services/hackbot-ui/lib/transcripts.ts @@ -0,0 +1,78 @@ +import type { ArtifactRef } from "./types"; + +// Claude Code session transcripts, published by hackbot-runtime under +// transcripts/.jsonl with subagents beside them under +// transcripts//subagents/agent-.{jsonl,meta.json}. +export const TRANSCRIPTS_PREFIX = "transcripts/"; + +const SESSION_RE = /^transcripts\/([^/]+)\.jsonl$/; +const SUBAGENT_RE = + /^transcripts\/[^/]+\/subagents\/agent-(.+)\.(jsonl|meta\.json)$/; + +export interface TranscriptEntry { + timestamp?: string; + [key: string]: unknown; +} + +export interface SubagentFiles { + id: string; + jsonl: string; + meta: string | null; +} + +export interface TranscriptFiles { + sessions: string[]; + subagents: SubagentFiles[]; +} + +export function transcriptArtifacts(artifacts: ArtifactRef[]): TranscriptFiles { + const sessions: string[] = []; + const subagents = new Map(); + for (const { name } of artifacts) { + if (SESSION_RE.test(name)) { + sessions.push(name); + continue; + } + const m = SUBAGENT_RE.exec(name); + if (!m) continue; + const [, id, kind] = m; + const files = subagents.get(id) ?? { id, jsonl: "", meta: null }; + if (kind === "jsonl") files.jsonl = name; + else files.meta = name; + subagents.set(id, files); + } + return { + sessions: sessions.sort(), + subagents: [...subagents.values()].filter((s) => s.jsonl), + }; +} + +export function hasTranscripts(artifacts: ArtifactRef[]): boolean { + return artifacts.some((a) => SESSION_RE.test(a.name)); +} + +export function parseJsonl(text: string): TranscriptEntry[] { + return text + .split("\n") + .filter((line) => line.trim()) + .map((line) => JSON.parse(line) as TranscriptEntry); +} + +function firstTimestamp(entries: TranscriptEntry[]): string { + return entries.find((e) => e.timestamp)?.timestamp ?? ""; +} + +// One run is several sessions in a row (analysis, then fix). Chain them into +// one entry list, each session kept intact, in the order they started. +export function mergeSessions( + sessions: TranscriptEntry[][] +): TranscriptEntry[] { + return sessions + .map((entries, index) => ({ + entries, + index, + start: firstTimestamp(entries), + })) + .sort((a, b) => a.start.localeCompare(b.start) || a.index - b.index) + .flatMap((s) => s.entries); +} diff --git a/services/hackbot-ui/next.config.mjs b/services/hackbot-ui/next.config.mjs index 5bdbd424df..6bfaa64a0a 100644 --- a/services/hackbot-ui/next.config.mjs +++ b/services/hackbot-ui/next.config.mjs @@ -4,6 +4,8 @@ const nextConfig = { // Emit a self-contained server bundle so the Docker image stays small. output: "standalone", reactStrictMode: true, + // CommonJS with Node built-ins; loaded by the profile route at runtime. + serverExternalPackages: ["claude-profiler"], }; export default withSentryConfig(nextConfig, { diff --git a/services/hackbot-ui/package-lock.json b/services/hackbot-ui/package-lock.json index 360a74794b..c6ceb3e1ac 100644 --- a/services/hackbot-ui/package-lock.json +++ b/services/hackbot-ui/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@sentry/nextjs": "^10.74.0", "better-auth": "^1.6.22", + "claude-profiler": "https://github.com/fqueze/claude-profiler/archive/c709090c314fe1df3bd5e7ded4325c866c906d97.tar.gz", "diff2html": "^3.4.56", "next": "^15.5.22", "react": "^19.0.0", @@ -3191,6 +3192,15 @@ "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", "license": "MIT" }, + "node_modules/claude-profiler": { + "version": "1.1.0", + "resolved": "https://github.com/fqueze/claude-profiler/archive/c709090c314fe1df3bd5e7ded4325c866c906d97.tar.gz", + "integrity": "sha512-wpxQHG+RFuTkzqNmwH/M/Q4plql48/v1DyEEwjJVDDAUnNahrCMkOehZ+Yds9idIVMBCCnKO5u3ltLPaQHjcsQ==", + "license": "MIT", + "bin": { + "claude-profiler": "index.js" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", diff --git a/services/hackbot-ui/package.json b/services/hackbot-ui/package.json index eacf175a71..d5acfc95de 100644 --- a/services/hackbot-ui/package.json +++ b/services/hackbot-ui/package.json @@ -13,6 +13,7 @@ "dependencies": { "@sentry/nextjs": "^10.74.0", "better-auth": "^1.6.22", + "claude-profiler": "https://github.com/fqueze/claude-profiler/archive/c709090c314fe1df3bd5e7ded4325c866c906d97.tar.gz", "diff2html": "^3.4.56", "next": "^15.5.22", "react": "^19.0.0", From 9f4eb8301969c835e3b603aa55798ebc6a5d7496 Mon Sep 17 00:00:00 2001 From: Evgeny Pavlov Date: Thu, 17 Sep 2026 15:36:56 -0700 Subject: [PATCH 3/5] hackbot-ui: open a run in the Firefox Profiler from the Traces row --- services/hackbot-ui/README.md | 11 ++++ services/hackbot-ui/app/globals.css | 11 ++++ services/hackbot-ui/components/RunDetail.tsx | 44 +++++++++++++ services/hackbot-ui/lib/profiler.test.ts | 65 ++++++++++++++++++++ services/hackbot-ui/lib/profiler.ts | 44 +++++++++++++ 5 files changed, 175 insertions(+) create mode 100644 services/hackbot-ui/lib/profiler.test.ts create mode 100644 services/hackbot-ui/lib/profiler.ts diff --git a/services/hackbot-ui/README.md b/services/hackbot-ui/README.md index bb06a0c912..0a696ebddb 100644 --- a/services/hackbot-ui/README.md +++ b/services/hackbot-ui/README.md @@ -53,6 +53,17 @@ to `@mozilla.com` accounts: | Download artifact | `GET /runs/{run_id}/artifacts/{path}` † | | (available) | `GET /agents` | +## Firefox Profiler + +hackbot-runtime publishes the Claude Code session transcripts of a run as +`transcripts/*.jsonl` artifacts. When a run has them, the Traces row of its page +offers "Firefox Profiler" next to Weave: `GET /api/runs/{run_id}/profile` +(`lib/profile.ts`) downloads the transcripts and converts them with +[claude-profiler](https://github.com/fqueze/claude-profiler), and the page hands +the result to a `profiler.firefox.com/from-post-message/` window +(`lib/profiler.ts`). Nothing is exposed outside the SSO session; use the +profiler's own Upload button for a shareable link. + ## Local development 1. Install dependencies: diff --git a/services/hackbot-ui/app/globals.css b/services/hackbot-ui/app/globals.css index cca871a241..716287eaee 100644 --- a/services/hackbot-ui/app/globals.css +++ b/services/hackbot-ui/app/globals.css @@ -176,6 +176,17 @@ button.secondary { border: 1px solid var(--border); color: var(--muted); } +button.link { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--accent); +} +button.link:hover:not(:disabled) { + background: none; + text-decoration: underline; +} .badge { display: inline-block; diff --git a/services/hackbot-ui/components/RunDetail.tsx b/services/hackbot-ui/components/RunDetail.tsx index c6c7e865c4..9d232b500a 100644 --- a/services/hackbot-ui/components/RunDetail.tsx +++ b/services/hackbot-ui/components/RunDetail.tsx @@ -4,7 +4,9 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; +import { injectProfile, PROFILER_INJECT_URL } from "@/lib/profiler"; import { updateRunStatus } from "@/lib/store"; +import { hasTranscripts } from "@/lib/transcripts"; import { isFailed, isTerminal, @@ -82,6 +84,8 @@ export function RunDetail({ const [applyError, setApplyError] = useState(null); const [retriggering, setRetriggering] = useState(false); const [retriggerError, setRetriggerError] = useState(null); + const [profiling, setProfiling] = useState(false); + const [profileError, setProfileError] = useState(null); const timer = useRef | null>(null); const fetchRun = useCallback(async () => { @@ -172,6 +176,28 @@ export function RunDetail({ } }, [runId, router]); + const openProfile = useCallback(async () => { + setProfiling(true); + setProfileError(null); + // Opened from the click itself so popup blockers treat it as user-initiated; + // the profile is handed over once it is built. + const popup = window.open(PROFILER_INJECT_URL, "_blank"); + try { + if (!popup) throw new Error("the browser blocked the profiler window"); + const res = await fetch(`/api/runs/${runId}/profile`); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.error ?? `Request failed (${res.status})`); + } + await injectProfile(popup, await res.json()); + } catch (err) { + popup?.close(); + setProfileError((err as Error).message); + } finally { + setProfiling(false); + } + }, [runId]); + if (!run && error) { return
{error}
; } @@ -233,6 +259,11 @@ export function RunDetail({ {retriggerError && (
Retrigger failed: {retriggerError}
)} + {profileError && ( +
+ Could not open the profile: {profileError} +
+ )}

Run

@@ -258,6 +289,19 @@ export function RunDetail({ Weave + {hasTranscripts(run.artifacts) && ( + <> + {", "} + + + )}