Inside the issue-reporter modal
- Ref
- FLD-46BD95
- Published
- 2026-04-26
- Kind
- DEVLOG
- Project
- Sidebar Agent
- Author
- Roman Sanine
The issue reporter is the smallest possible product surface around an idea that turns out to be load-bearing: let the user file a bug, but make the file → fix loop autonomous. No tracker, no Jira ticket, no "we'll get to it." The user types what's wrong, the agent triages it, classifies it as hotfix or feature, kicks off a runner, and posts back when it's done. This entry is the technical anatomy of that surface.
The shape, top to bottom
┌─────────────────────────────────────────────┐
│ IssueReporterModal.vue (654 lines) │ ← user-facing
│ ↳ IssueReporterIssueCard (267 lines) │
│ ↳ IssueReporterConversation ( 90 lines) │
│ ↳ IssueReporterPendingQuestion( 31 lines) │
│ ↳ useIssueReporter() │ ← composable
└─────────────────────────────────────────────┘
│ tRPC + SSE
▼
┌─────────────────────────────────────────────┐
│ /api/issue-reports/[id].get .patch │ ← REST surface
│ /api/issue-reports/stream.get │ ← SSE
│ issue-events.ts (in-proc bus) │
│ issue-runner.ts (spawns runner) │
│ issue-runner-events.ts (event reducer) │
│ issue-queue-watchdog (nitro plugin) │
└─────────────────────────────────────────────┘
│ child_process.spawn()
▼
┌─────────────────────────────────────────────┐
│ scripts/issue-agent-runner.ts │ ← detached worker
│ ↳ runs Claude Code session │
│ ↳ HMAC-signed callbacks │
│ ↳ writes status events back │
└─────────────────────────────────────────────┘
│ logs
▼
.issue-reports/runs/<id>.log
Four layers: UI, event bus, runner, worker. Each is replaceable in isolation.
The composable
useIssueReporter() is the only thing the modal sees. It exposes:
const {
isOpen, prompt, issues, loading, editingId,
attachments, pageContexts,
open, close, submit, deleteIssue,
startEdit, cancelEdit, updateIssue,
postMessage, answerQuestion, forceStart, recheckQueues,
} = useIssueReporter()
The composable owns:
- Local form state — the textarea, attachments (screenshots/voice clips), and the implicit "page contexts" (URL, route, page tools list, scrollback) captured at submission time.
- Polled list of issues — refetched when SSE pushes an
updateevent. - Edit mode — turning an existing issue back into an editable draft.
- Question-answer loop — when an issue's runner asks a clarifying question (
AskUserQuestion), the answer comes through this path. - Force-start and recheck — manual overrides for stuck queues.
State lives in a Pinia-style provide/inject pattern so opening the modal from anywhere in the app shares the same draft and issue list.
Issue lifecycle
An issue moves through a state machine:
| Status | Meaning |
|---|---|
open |
Filed, not yet picked up by a runner |
active |
A runner is processing it |
awaiting-user |
Runner is blocked on a clarification question |
closed |
Done, fix applied + verified |
failed |
Runner exited non-zero or hit an unrecoverable error |
cancelled |
User killed it before completion |
There's also an orthogonal workStatus for the active phase: reading | writing | testing. The UI shows this as a small label next to the status — "active · reading" tells the user the agent is still gathering context, hasn't started writing yet.
Triage and the page-tag
Every issue has a targetTag field that scopes it to a route. Filed from /admin/agents? Tagged /admin/agents. The UI shows page-scoped issues in the modal by default but lets you switch to a global view:
type Scope = 'page' | 'global' | 'issues-modal'
const pageIssues = computed(() =>
issues.value.filter(issue =>
issue.targetTag === currentTargetTag.value
|| (!issue.targetTag && issue.page.replace(/^https?:\/\/[^/]+/, '').split('?')[0] === route.path),
),
)
This matters because filing "this is broken" without context is what kills bug trackers. The targetTag plus the captured page context (route params, scroll position, what the user was looking at) make the report self-contained.
SSE for live status
The modal stays in sync with running issues over a single SSE connection:
// /api/issue-reports/stream.get.ts
setResponseHeaders(event, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
})
res.write(':ok\n\n')
const heartbeat = setInterval(() => {
if (!res.writable) { clearInterval(heartbeat); return }
res.write(':heartbeat\n\n')
}, 15_000)
const unsub = onIssueUpdate(() => {
res.write('event: update\ndata: {}\n\n')
})
The payload is intentionally minimal — just {}. The client's response is "refetch the issue list." This avoids two failure modes: stale partial state (where the SSE event diverges from the database) and oversized SSE messages (which proxies sometimes truncate).
The onIssueUpdate bus is in-process:
// issue-events.ts
const listeners = new Set<() => void>()
export function emitIssueUpdate() {
for (const fn of listeners) {
try { fn() } catch {}
}
}
export function onIssueUpdate(fn: () => void): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}
Anything that writes to an issue file calls emitIssueUpdate(). The SSE handler subscribes, the heartbeat keeps the proxy from dropping the connection, the request-close cleanup unsubscribes.
The runner subsystem
This is the part that does the actual work. When an issue moves from open → active, the server spawns a detached child process:
// issue-runner.ts (sketch)
const child = spawn('node', ['--loader', 'tsx', runnerScriptPath(), id], {
detached: true,
stdio: ['ignore', logFd, logFd],
env: {
...process.env,
ISSUE_ID: id,
ISSUE_CALLBACK_URL: callbackUrl,
ISSUE_TOKEN: mintIssueToken(id),
},
})
child.unref()
A few things matter here:
- Detached. The runner outlives the parent. If the dev server restarts, in-flight runners keep going.
- Log file per run.
.issue-reports/runs/<id>.logcaptures stdout+stderr, viewable in the issue card. - HMAC token. The callback URL the runner uses to post status updates is authenticated with a token signed against a per-installation secret stored at
.issue-reports/.secret(file mode0600). The runner can talk to the server without the user being logged in; nobody else can. - Headless agent. The runner script runs Claude Code in headless mode against the project, with system prompts that point it at the issue file and the constitution.
Event reducer
The runner emits structured events; the server reduces them onto the issue record:
// issue-runner-events.ts
export function applyIssueRunnerEvent(
issue: IssueReport,
event: IssueRunnerEvent,
now = new Date().toISOString(),
): IssueReport {
if (event.type === 'runner') return { ...issue, autoloopRunner: { ... } }
if (event.type === 'workStatus') return { ...issue, workStatus: event.workStatus }
if (event.type === 'headline') return { ...issue, headline: event.headline }
if (event.type === 'message') return { ...issue, messages: [...issue.messages, event.message] }
if (event.type === 'question') return { ...issue, status: 'awaiting-user', ... }
if (event.type === 'completed') return { ...issue, status: 'closed', ... }
// ...
}
Pure function, no I/O, easy to test. The handler that calls it owns the file write + emitIssueUpdate().
The question-answer loop
When the runner needs clarification, it emits a question event:
{
type: 'question',
question: {
id: 'q-7f3a...',
text: 'Should I match the existing button styling, or follow the new design system?',
options: [
{ id: 'a', label: 'Existing button styling' },
{ id: 'b', label: 'New design system' },
],
}
}
The reducer pushes this into the message thread and flips status to awaiting-user. The modal's IssueReporterPendingQuestion component renders the options. When the user picks one, answerQuestion(issueId, questionId, answer) POSTs back, the server appends the answer to the message thread, the runner — which has been polling its issue file — sees the answer and resumes.
The polling-not-pushing direction matters. The runner is a Node child process that doesn't have a persistent connection back to the server; it polls its issue file every few seconds for new question-answers. Slightly less elegant than push, but recoverable if the runner crashes mid-question.
The watchdog
issue-queue-watchdog.ts is a nitro plugin that runs on server start:
export default defineNitroPlugin(async () => {
// On boot: scan .issue-reports for issues with status=active but no live runner
// → mark as failed OR re-spawn, depending on staleness
// Then: every 30s, recheck queues for orphaned issues
})
This catches the failure modes that scare people about detached processes:
- Server restarts mid-issue → next boot detects orphaned
activeissues, re-spawns or fails them. - Runner segfaults → the child watcher in the parent process catches the exit code and writes
failed+ stderr tail. - Issue stuck in
awaiting-userfor >24h → markedcancelledto keep the queue clean.
The watchdog's heuristics live in QueueHealth records (also in issue-runner.ts):
interface QueueHealth {
pendingCount: number
activeCount: number
backlogCount: number
oldestPendingAgeMs?: number
oldestActiveAgeMs?: number
stalePending: boolean
staleActive: boolean
blocked: boolean
}
The UI exposes a "recheck queues" button that triggers a manual sweep — useful when something looks wrong and you don't want to wait for the next interval.
Storage
Everything lives in .issue-reports/ at the project root:
.issue-reports/
.secret # HMAC secret, mode 0600
queue-exec.md # legacy, retained for back-compat
<issue-id>.json # one file per issue, full history
runs/
<issue-id>.log # combined stdout/stderr from runner
No database. The trade-off is deliberate: issue records need to survive dev-server restarts and be inspectable from a shell, neither of which a Mongo collection gives you for free during agent debugging. When the issue volume justifies it, this layer can move to a collection without changing the rest of the architecture.
What this enables
- A real-time bug-fix loop. File a bug at 2pm, get a
closedstatus at 2:14pm with the diff and the test that proves it. Or get anawaiting-userand answer a clarifying question. Either way, the user is never the bottleneck. - Survives restarts. Detached runners + watchdog + file-backed storage means dev-server crashes don't lose work.
- Auditable. Every issue's full conversation, runner log, status transitions, and answers are on disk.
.issue-reports/<id>.jsonis the system of record. - Composable with the architecture system. A closed issue triggers the
.architecture/integration: trivial fixes update the bug ledger, complex ones expand into feature folders. The triage step happens inside the runner, so by the time the user seesclosed, the architecture state is already consistent.
Friction
A few things that aren't smooth yet:
- Runner-to-server polling for question answers is the design choice that costs the most. A websocket would be cleaner, but the recoverability story for "runner reconnects after parent restart" gets significantly more complex. The current poll-every-2s is the pragmatic tradeoff.
- HMAC tokens have no expiry. A token minted for a runner that lives 30 minutes is valid for that whole window. Acceptable now (single-host installations); needs replacing with short-lived tokens before this goes multi-tenant.
- Log file growth.
.issue-reports/runs/<id>.logdoesn't rotate. After a few hundred issues, the directory is a few hundred MB. Not urgent, but not zero.
Where this is going
Two directions:
- Issue templates per route. A complaint filed from the agents page should pre-populate "agent: , route: /admin/agents/, last action: ." The plumbing for this is half-built —
pageContextsalready captures most of what's needed. - Multi-runner orchestration. Right now each issue gets one runner. Some issues — especially "audit this whole feature" type tasks — would benefit from a parent runner that spawns N child runners on subtasks. The runner spawn machinery is already a function call; it's the supervision protocol that's missing.
The current system is the smallest viable shape. It works, it's recoverable, and it's a shape that can grow.