// The task's event log (plan 17 section 3.2): the unified // ledger's human view. The feed is client-side over the // plan-17 endpoints, and its contract is: // // CHRONOLOGY - rows render OLDEST at the top and NEWEST at // the bottom, like a terminal transcript. The SSE stream // replays the most-recent-30 window oldest-first and then // appends every new row, so live traffic just extends the // tail of the feed. // // SCROLL - the feed ([data-log-feed], max-height 72vh, // overflow-y auto) is scrolled to the bottom on the first // render that carries rows, and afterwards sticks to the // bottom ONLY when the reader was already at (or within // 48px of) the bottom before the render. A reader who // scrolled up into the history is never yanked down. // // PAGING - the load-older control sits above the feed // (older history lives above it) and pages backwards via // GET /tasks//events?before=&limit=30. // The server serves each page newest-first; the client // reverses it to oldest-first and prepends, restoring the // viewport by the scrollHeight delta so the rows under the // reader's cursor do not move. A page whose `more` flag is // false marks the ledger exhausted and hides the control. // // STREAM - the server's SSE frames carry only id: and // data: lines (no `event:` name), so every frame lands on // `onmessage` and no named listener can ever fire. The // heartbeat frame's data is the literal string "ping"; // every other frame is one JSON ledger row, ingested with // the id-dedupe (which also absorbs EventSource's replay // after a reconnect, resent from the last seen id). // // Turn cards group a run's turn_text + tool_call + // tool_result events by payload.turn (per session). // // PAYLOADS - the daemon serialises a tool call's arguments // and a tool result's envelope to a JSON STRING before it // stores them: args is a string like '{"command":"ls"}' // (never an object) and result is a string holding the // {"ok":..,"exit_code":..,"stdout":..,"stderr":.., // "duration_ms":..,"bytes_out":..,"bytes_err":.., // "truncated":..} envelope. Every render path parses these // members through parseMaybeJson first and falls back to // the raw string when it is not JSON - data is never // hidden. The command the agent ran must always be // visible: command_output prefixes '$ ', and each // tool_call renders through a tailored body // (tailoredCallBody) - run_command heads with '$ cmd', the // file tools show paths with folded content, // replace_in_file renders a -old/+new diff, the text tools // render prose - with the generic JSON view as the // backstop for unknown tools and truncated args. Results collapse // behind a summary carrying exit code, byte counts (from // the envelope when present), the truncated flag and // duration. Filters // (commands/results/comments/transitions/usage) and the // full-text search run client-side over the loaded window. // All rendering goes through textContent/createElement - // event payloads never touch innerHTML. // // This whole script body is deliberately ASCII-only // (comments included): the page compiler HTML-escapes // script bodies, so any non-ASCII literal would come back // out of the browser as literal entity text - the entity // name spelled out - instead of the character it names. // Non-ASCII characters are written as JS unicode escapes // (\u00b7 etc). if (window.statum) { (function () { 'use strict'; // How close to the bottom (px) still counts as // "reading the tail" for the stick rule. var STICK_THRESHOLD = 48; var PAGE_SIZE = 30; var state = { events: [], oldest: 0, exhausted: false, loading: false, seeded: false, filters: { commands: true, results: true, comments: true, transitions: true, usage: true }, search: '' }; var feed = null; var moreButton = null; var stream = null; var liveBadge = null; var liveLabel = null; var taskStatus = ''; var streamState = 'live'; // The badge reflects the TASK first: only a working (or // assigned) task streams live events, so every other // status names itself on the pill - still, no pulse, // the task page's own display words. The stream's // health (reconnecting...) only rides a live task. var LIVE_STATUSES = { working: 1, assigned: 1 }; var BADGE_LABELS = { suspended: 'parked', review_requested: 'review requested' }; function updateBadge() { if (!liveBadge) return; var cls = 'log-live'; var label; if (LIVE_STATUSES[taskStatus]) { if (streamState === 'retry') { cls += ' retry'; label = 'reconnecting...'; } else { label = 'live'; } } else { cls += ' still badge badge-' + taskStatus; label = BADGE_LABELS[taskStatus] || taskStatus || 'idle'; } liveBadge.className = cls; if (liveLabel) { liveLabel.textContent = label; } } function setLive(state) { streamState = state; updateBadge(); } function kindClass(kind) { return 'event-kind-' + String(kind || '').replace(/[^a-z0-9-]/g, ''); } function filterOf(kind) { if (kind === 'tool_call') return 'commands'; if (kind === 'tool_result' || kind === 'command_output') return 'results'; if (kind === 'comment' || kind === 'answer' || kind === 'verdict' || kind === 'yeehaw') return 'comments'; if (kind === 'usage') return 'usage'; return 'transitions'; } function visible(evt) { if (!state.filters[filterOf(evt.kind)]) return false; if (state.search.length > 0) { var haystack = evt.kind + ' ' + evt.actor + ' ' + JSON.stringify(evt.payload || {}) + ' ' + strText((evt.payload || {}).args) + ' ' + strText((evt.payload || {}).result); if (haystack.toLowerCase().indexOf(state.search.toLowerCase()) === -1) { return false; } } return true; } // state.events is kept oldest-first (ascending id): // insert before the first newer row, skip ids we // already hold (SSE replay after a reconnect sends // the last frame again). function ingest(evt) { var i = 0; while (i < state.events.length && state.events[i].id < evt.id) { i++; } if (i < state.events.length && state.events[i].id === evt.id) return; state.events.splice(i, 0, evt); if (evt.id < state.oldest || state.oldest === 0) { state.oldest = evt.id; } } // True when the feed is scrolled to (or within // STICK_THRESHOLD of) its bottom - a feed shorter // than its viewport counts as at-bottom. function atBottom() { if (!feed) return true; return feed.scrollHeight - feed.scrollTop - feed.clientHeight <= STICK_THRESHOLD; } // Pin the feed to its bottom edge (the newest row). function scrollToBottom() { if (!feed) return; feed.scrollTop = feed.scrollHeight; } // The daemon JSON-encodes tool arguments and tool // result envelopes into STRINGS before storing them; // older/fake pushes send plain objects. Parse the // string forms, pass objects through, and answer // undefined when a string is not JSON (the caller // then shows the raw text so data is never hidden). function parseMaybeJson(value) { if (typeof value !== 'string') return value; try { return JSON.parse(value); } catch (error) { return undefined; } } // True for a parsed member we can treat as a bag of // properties (the object-shaped push, or a string // that decoded to an object). function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } function strText(value) { return typeof value === 'string' ? value : ''; } function strBytes(value) { return typeof value === 'string' ? value.length : 0; } function fmtDuration(ms) { if (ms < 1000) return ms + ' ms'; return (ms / 1000).toFixed(1) + ' s'; } function fmtBytes(n) { if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB'; return (n / (1024 * 1024)).toFixed(1) + ' MiB'; } function preLine(text) { var pre = document.createElement('pre'); pre.className = 'event-pre'; pre.textContent = text; return pre; } // The parsed result envelope: the daemon stores it // as a JSON string ({"ok":..,"exit_code":.., // "stdout":..,"stderr":..,"duration_ms":.., // "bytes_out":..,"bytes_err":..,"truncated":..}); // the legacy object-shaped payload is the fallback. function resultEnvelope(evt) { var p = evt.payload || {}; var parsed = parseMaybeJson(p.result); if (isPlainObject(parsed)) return parsed; if (isPlainObject(p)) return p; return {}; } // Command plumbing: exit status, the streams and // their sizes, plus the ledger's own framing keys. // An envelope carrying ONLY these said nothing worth // printing; anything else in it (a task_id, // "recorded", a tasks list) IS the tool's answer // and must be rendered, not swallowed by "(no // output)". var RESULT_PLUMBING = { ok: 1, exit_code: 1, stdout: 1, stderr: 1, bytes_out: 1, bytes_err: 1, truncated: 1, duration_ms: 1, turn: 1, tool: 1 }; function resultExtras(envelope) { var extras = {}; var keys = Object.keys(envelope); for (var i = 0; i < keys.length; i++) { if (RESULT_PLUMBING[keys[i]] === undefined && keys[i] !== 'error' && keys[i] !== 'note' && keys[i] !== 'timeout') { extras[keys[i]] = envelope[keys[i]]; } } return extras; } function resultSummary(evt, envelope) { var wrap = document.createElement('summary'); var failed = (typeof envelope.exit_code === 'number' && envelope.exit_code !== 0) || envelope.ok === false; wrap.className = 'result-summary' + (failed ? ' failed' : ''); wrap.appendChild(document.createTextNode('result')); var bits = resultBits(evt, envelope); if (bits.length > 0) { wrap.appendChild(document.createTextNode(' (' + bits.join(', ') + ')')); } return wrap; } // The summary's metric bits (exit, size, duration) - // shared by the expander summary and the inline line. function resultBits(evt, envelope) { var p = evt.payload || {}; var bits = []; if (envelope.exit_code !== undefined && envelope.exit_code !== null) { bits.push('exit ' + envelope.exit_code); } else if (envelope.ok === false) { bits.push('failed'); } // Prefer the envelope's own byte counters (they // count the pre-truncation output); fall back to // measuring the strings we hold. var bytes; if (typeof envelope.bytes_out === 'number' && typeof envelope.bytes_err === 'number') { bytes = envelope.bytes_out + envelope.bytes_err; } else { bytes = strBytes(envelope.stdout) + strBytes(envelope.stderr); } if (bytes > 0) bits.push(fmtBytes(bytes)); if (envelope.truncated) bits.push('truncated'); if (typeof envelope.duration_ms === 'number') { bits.push(fmtDuration(envelope.duration_ms)); } if (p.turn !== undefined && p.turn !== null) bits.push('turn ' + p.turn); // A result STRING that is not JSON carries no // envelope; its own size is the only metric we // have, and it is about to be rendered raw. if (bits.length === 0 && typeof p.result === 'string' && !isPlainObject(parseMaybeJson(p.result))) { bits.push(fmtBytes(p.result.length)); } return bits; } // A result renders INLINE when its body is short - a // one-line acknowledgement, a replacement count, a few // lines of output - with the metric bits as a quiet // prefix line; only long bodies collapse behind the // expander summary. An ATTACHED result (riding with its // call) is always inline - the output must be visible, // not behind a click - with oversized bodies clamped by // CSS instead. var INLINE_RESULT_LIMIT = 220; function resultView(evt, envelope, forceInline) { var wrap = document.createDocumentFragment(); var node = resultBody(evt, envelope); var text = (node.textContent || '').trim(); if (forceInline || text.length <= INLINE_RESULT_LIMIT) { var bits = resultBits(evt, envelope); if (bits.length > 0) { wrap.appendChild(para(bits.join(' \u00b7 '), 'result-meta')); } wrap.appendChild(node); return wrap; } var details = document.createElement('details'); details.className = 'event-result'; details.appendChild(resultSummary(evt, envelope)); details.appendChild(node); return wrap; } function resultBody(evt, envelope) { var p = evt.payload || {}; var div = document.createElement('div'); div.className = 'event-result-body'; // A result string that is NOT JSON is the output // text itself (the daemon sends truncated raw // output this way): show it verbatim. var out = ''; var err = ''; var parsed = parseMaybeJson(p.result); if (isPlainObject(parsed)) { out = strText(envelope.stdout); err = strText(envelope.stderr); } else if (typeof parsed === 'string') { out = parsed; // a JSON string literal } else if (typeof p.result === 'string') { out = p.result; } else { // Legacy object-shaped payload: the members // sit on the payload itself. out = strText(envelope.stdout); err = strText(envelope.stderr); } if (out.length > 0) { div.appendChild(preLine(out)); } if (err.length > 0) { var errPre = preLine(err); errPre.className = 'event-pre event-stderr'; div.appendChild(errPre); } if (out.length === 0 && err.length === 0) { // Empty stdout AND stderr. An error/note/ // timeout member still said something, and a // non-run_command tool's answer IS the // envelope - render it through the tailored // result body first (a read window, a // replacement count, a task list), then the // raw JSON. Only an envelope with nothing left // to say is "(no output)". var note = strText(envelope.error) || strText(envelope.note) || (envelope.timeout ? 'timed out' : ''); if (note.length > 0) { var notePre = preLine(note); notePre.className = 'event-pre event-stderr'; div.appendChild(notePre); } else { var extras = resultExtras(envelope); var bespoke = tailoredResultBody(strText(p.tool), extras); if (bespoke) { div.appendChild(bespoke); } else if (Object.keys(extras).length > 0) { var compact = JSON.stringify(extras); div.appendChild(preLine(compact.length <= 120 ? compact : JSON.stringify(extras, null, 2))); } else { var empty = document.createElement('p'); empty.className = 'text-muted'; empty.textContent = '(no output)'; div.appendChild(empty); } } } return div; } // ---- tailored result bodies ---- // // The daemon's file and task tools answer with // structured extras (a line window of a file, a // replacement count, a task list) that deserve better // than a JSON dump. Answers null for the generic view. function okPara(text) { var node = para(text); node.className = 'result-ok'; return node; } function taskList(items) { var list = document.createElement('ul'); list.className = 'result-tasks'; items.forEach(function (item) { if (!isPlainObject(item)) return; var li = document.createElement('li'); li.textContent = '#' + (item.id !== undefined ? item.id : '?') + ' ' + strText(item.title); var status = strText(item.status); if (status.length > 0) { var chip = document.createElement('span'); chip.className = 'badge event-kind-state_change'; chip.textContent = status; li.appendChild(chip); } list.appendChild(li); }); return list; } function tailoredResultBody(tool, extras) { var frag = document.createDocumentFragment(); if (strText(extras.content).length > 0 || typeof extras.total_lines === 'number') { // read_file's answer: a line window of a file; // read_binary's: a byte window. The meta line // names the window; the content folds behind an // expander - a read is reference material, not // the action's output. var meta = []; if (strText(extras.path).length > 0) { meta.push(strText(extras.path)); } if (typeof extras.total_lines === 'number') { meta.push('lines ' + (typeof extras.line_start === 'number' ? extras.line_start : 1) + '-' + (typeof extras.line_end === 'number' ? extras.line_end : extras.total_lines) + ' of ' + extras.total_lines); if (typeof extras.bytes === 'number') { meta.push(fmtBytes(extras.bytes)); } } else { if (strText(extras.representation).length > 0) { meta.push(strText(extras.representation)); } if (typeof extras.bytes_read === 'number') { meta.push(fmtBytes(extras.bytes_read) + (typeof extras.total_bytes === 'number' ? ' of ' + fmtBytes(extras.total_bytes) : '')); } if (typeof extras.offset === 'number' && extras.offset > 0) { meta.push('at ' + fmtBytes(extras.offset)); } } if (extras.truncated) meta.push('truncated'); if (meta.length > 0) { frag.appendChild(para(meta.join(' \u00b7 '), 'result-meta')); } if (strText(extras.content).length > 0) { var shown = strText(extras.content); frag.appendChild(fold('content \u00b7 ' + fmtBytes(shown.length), function () { return preLine(shown); })); } return frag; } if (tool === 'replace_in_file' && typeof extras.replacements === 'number') { return okPara(extras.replacements + ' replacement' + (extras.replacements === 1 ? '' : 's') + ' in ' + strText(extras.path) + ' \u00b7 ' + fmtBytes(typeof extras.bytes_written === 'number' ? extras.bytes_written : 0)); } if (tool === 'write_file' && typeof extras.bytes === 'number') { return okPara('wrote ' + strText(extras.path) + ' \u00b7 ' + fmtBytes(extras.bytes)); } if (tool === 'overwrite_file' && typeof extras.bytes_written === 'number') { return okPara('rewrote ' + strText(extras.path) + ' \u00b7 ' + fmtBytes(extras.bytes_written) + (typeof extras.previous_bytes === 'number' ? ' (was ' + fmtBytes(extras.previous_bytes) + ')' : '')); } if (tool === 'create_task' && typeof extras.task_id === 'number') { return okPara('created task #' + extras.task_id + (strText(extras.status).length > 0 ? ' \u00b7 ' + strText(extras.status) : '')); } if (tool === 'get_task' && extras.id !== undefined) { return taskList([extras]); } if (Array.isArray(extras.tasks)) return taskList(extras.tasks); if (Array.isArray(extras.goals)) return taskList(extras.goals); var keys = Object.keys(extras); var allTrue = keys.length > 0; var allScalars = keys.length > 0 && keys.length <= 4; for (var i = 0; i < keys.length; i++) { var value = extras[keys[i]]; if (value !== true) allTrue = false; if (value !== null && typeof value === 'object') allScalars = false; } // A pure acknowledgement ({recorded:true}, // {accepted:true}): a green ok line, not JSON. if (allTrue) return okPara('ok'); if (allScalars) { var bits = []; for (var j = 0; j < keys.length; j++) { bits.push(keys[j] + ': ' + extras[keys[j]]); } return para(bits.join(' \u00b7 '), 'result-meta'); } return null; } // The tool call renders a TAILORED body per tool: the // head line always carries the tool and its target (a // $ command, a file path, a task id) and the payload // renders in the shape a human scans - a diff for // replace_in_file, a folded blob for file content, // prose for the text tools. A bespoke renderer answers // null when its fields are missing (a truncated args // string clamps mid-JSON and stops parsing); the // generic view then shows the raw text so data is // never hidden. function toolCallBody(p) { var frag = document.createDocumentFragment(); var tool = strText(p.tool).length > 0 ? strText(p.tool) : 'tool'; var parsed = parseMaybeJson(p.args); var args = isPlainObject(parsed) ? parsed : null; var bespoke = args ? tailoredCallBody(tool, args) : null; if (bespoke) return bespoke; var head = preLine(tool); head.className = 'event-pre event-command'; frag.appendChild(head); if (args) { var rest = argsWithout(args, 'command'); if (rest.length > 0) frag.appendChild(preLine(rest)); } else if (strText(p.args).length > 0) { // Args that are not JSON: the daemon sent // text; never hide it behind a parse failure. frag.appendChild(preLine(p.args)); } else if (p.args !== undefined && p.args !== null && typeof p.args !== 'string') { frag.appendChild(preLine(JSON.stringify(p.args, null, 2))); } return frag; } // ---- tailored rendering helpers ---- function para(text, className) { var node = document.createElement('p'); if (className) node.className = className; node.textContent = text; return node; } // The one-line tool head: the primary text plus muted // detail bits separated by middle dots. function headLine(text, bits) { var pre = preLine(text); pre.className = 'event-pre event-command'; if (bits && bits.length > 0) { var tail = document.createElement('span'); tail.className = 'text-muted'; tail.textContent = ' \u00b7 ' + bits.join(' \u00b7 '); pre.appendChild(tail); } return pre; } // A collapsed fold for long payloads (file content, // plans, reports): the label carries the size so the // feed stays scannable and the content is one click // away. function fold(label, build) { var details = document.createElement('details'); details.className = 'event-fold'; var summary = document.createElement('summary'); summary.textContent = label; details.appendChild(summary); details.appendChild(build()); return details; } function contentFold(label, text) { if (strText(text).length === 0) return null; var body = text; return fold(label + ' \u00b7 ' + fmtBytes(body.length), function () { return preLine(body); }); } // Integer with thousands separators (a plain loop, no // locale dependence). function fmtInt(n) { var digits = String(Math.abs(Math.round(n))); var out = ''; for (var i = 0; i < digits.length; i++) { if (i > 0 && (digits.length - i) % 3 === 0) out += ','; out += digits.charAt(i); } return (n < 0 ? '-' : '') + out; } // The first non-empty string member among the usual // text field names - the prose of an event whose kind // has no bespoke branch. function pickText(p) { var names = ['text', 'body', 'question', 'answer', 'verdict', 'message', 'reason']; for (var i = 0; i < names.length; i++) { if (strText(p[names[i]]).length > 0) return strText(p[names[i]]); } return ''; } // ---- the replace_in_file diff ---- // // Common leading/trailing lines become context // (clamped to a couple of lines each side); the // differing middle renders as -old/+new. A multi-hunk // edit collapses into one hunk - an honest // replacement view, never a wrong one. Oversized // sides keep their head and tail around an ellipsis // row. var DIFF_CONTEXT = 2; var DIFF_MAX_LINES = 400; function diffRows(oldText, newText) { var oldLines = oldText.split('\n'); var newLines = newText.split('\n'); var start = 0; while (start < oldLines.length && start < newLines.length && oldLines[start] === newLines[start]) { start++; } var endOld = oldLines.length; var endNew = newLines.length; while (endOld > start && endNew > start && oldLines[endOld - 1] === newLines[endNew - 1]) { endOld--; endNew--; } var rows = []; var i; var hasMiddle = endOld > start || endNew > start; if (hasMiddle) { for (i = Math.max(0, start - DIFF_CONTEXT); i < start; i++) { rows.push([' ', oldLines[i]]); } pushSide(rows, oldLines, start, endOld, '-'); pushSide(rows, newLines, start, endNew, '+'); for (i = endOld; i < Math.min(oldLines.length, endOld + DIFF_CONTEXT); i++) { rows.push([' ', oldLines[i]]); } } return rows; } function pushSide(rows, lines, begin, end, marker) { var count = end - begin; var half = Math.floor(DIFF_MAX_LINES / 2); if (count <= DIFF_MAX_LINES) { for (var i = begin; i < end; i++) rows.push([marker, lines[i]]); return; } for (var j = begin; j < begin + half; j++) rows.push([marker, lines[j]]); rows.push([marker, '... ' + (count - half * 2) + ' more lines ...']); for (var k = end - half; k < end; k++) rows.push([marker, lines[k]]); } function diffBlock(oldText, newText) { var rows = diffRows(oldText, newText); if (rows.length === 0) return null; var div = document.createElement('div'); div.className = 'event-diff'; rows.forEach(function (row) { var line = document.createElement('div'); line.className = 'diff-line diff-' + (row[0] === '-' ? 'del' : (row[0] === '+' ? 'add' : 'ctx')); line.textContent = row[0] + ' ' + row[1]; div.appendChild(line); }); return div; } // ---- the write-family rendering ---- // // The file languages worth naming on a head line (an // extension chip); anything else renders no language // bit. The ledger clamps args to 4 KiB, so the content // always rides VISIBLE (never behind a fold) with CSS // clamping the block. var FILE_LANGUAGES = { '.vala': 'Vala', '.vapi': 'Vapi', '.c': 'C', '.h': 'C', '.py': 'Python', '.js': 'JavaScript', '.ts': 'TypeScript', '.html': 'HTML', '.css': 'CSS', '.json': 'JSON', '.md': 'Markdown', '.sh': 'Shell', '.bash': 'Shell', '.xml': 'XML', '.yml': 'YAML', '.yaml': 'YAML', '.sql': 'SQL', '.toml': 'TOML', '.ini': 'INI', '.cfg': 'Config', '.diff': 'Diff', '.patch': 'Diff', '.rst': 'reST', '.txt': 'Text' }; function languageOf(path) { var lower = path.toLowerCase(); var slash = lower.lastIndexOf('/'); var name = slash >= 0 ? lower.substring(slash + 1) : lower; if (name === 'meson.build') return 'Meson'; if (name === 'makefile') return 'Make'; var dot = name.lastIndexOf('.'); if (dot < 1 || dot === name.length - 1) return ''; return FILE_LANGUAGES[name.substring(dot)] || ''; } function contentBits(content) { var lines = content.split('\n').length; return [lines + ' line' + (lines === 1 ? '' : 's'), fmtBytes(content.length)]; } function contentBlock(content) { var pre = preLine(content); pre.className = 'event-pre'; return pre; } // ---- the bespoke tool bodies ---- // // Each branch answers a DocumentFragment when it can // render the call faithfully, null to fall back to the // generic view. function tailoredCallBody(tool, args) { var frag = document.createDocumentFragment(); var path = strText(args.path); var command = strText(args.command); var question = strText(args.question); var bits = []; var node; if (tool === 'run_command') { if (command.length === 0) return null; if (typeof args.timeout_ms === 'number') { bits.push('timeout ' + Math.round(args.timeout_ms / 1000) + 's'); } frag.appendChild(headLine('$ ' + command, bits)); return frag; } if (tool === 'read_file') { if (path.length === 0) return null; if (typeof args.offset === 'number' && args.offset > 1) { bits.push('from line ' + args.offset); } if (typeof args.limit === 'number') { bits.push(args.limit + ' lines'); } frag.appendChild(headLine('read_file ' + path, bits)); return frag; } if (tool === 'read_binary') { if (path.length === 0) return null; if (strText(args.representation).length > 0) { bits.push(strText(args.representation)); } if (typeof args.offset === 'number' || typeof args.length === 'number') { bits.push('bytes ' + (args.offset || 0) + '-' + ((args.offset || 0) + (args.length || 4096) - 1)); } frag.appendChild(headLine('read_binary ' + path, bits)); return frag; } if (tool === 'write_file' || tool === 'overwrite_file' || tool === 'temp_doc') { var content = strText(args.content); if (content.length === 0) return null; if (tool === 'temp_doc') { frag.appendChild(headLine('temp_doc', contentBits(content))); } else { if (path.length === 0) return null; var bits2 = contentBits(content); var language = languageOf(path); if (language.length > 0) bits2.unshift(language); frag.appendChild(headLine(tool + ' ' + path, bits2)); } frag.appendChild(contentBlock(content)); return frag; } if (tool === 'replace_in_file') { if (path.length === 0 || strText(args.old).length === 0) return null; if (typeof args.expected_count === 'number') { bits.push('expect ' + args.expected_count); } frag.appendChild(headLine('replace_in_file ' + path, bits)); node = diffBlock(strText(args.old), strText(args.new)); if (node) frag.appendChild(node); return frag; } if (tool === 'temp_doc') { if (strText(args.content).length === 0) return null; frag.appendChild(headLine('temp_doc', [fmtBytes(strText(args.content).length)])); node = contentFold('content', args.content); if (node) frag.appendChild(node); return frag; } if (tool === 'query_command' || tool === 'query_file' || tool === 'query_llm') { var prompt = question.length > 0 ? question : strText(args.prompt); if (tool === 'query_command' && command.length > 0) { frag.appendChild(headLine('$ ' + command)); } else if (path.length > 0) { frag.appendChild(headLine(tool + ' ' + path)); } else { frag.appendChild(headLine(tool)); } if (prompt.length > 0) { frag.appendChild(para('Q: ' + prompt, 'event-question')); } return frag; } if (tool === 'create_task') { if (strText(args.title).length === 0) return null; if (strText(args.mode).length > 0) bits.push(strText(args.mode)); if (strText(args.branch).length > 0) { bits.push('branch ' + strText(args.branch)); } frag.appendChild(headLine('create_task: ' + strText(args.title), bits)); if (strText(args.brief).length > 0) { node = contentFold('brief', args.brief); if (node) frag.appendChild(node); } return frag; } if (tool === 'enqueue_task' || tool === 'delete_task' || tool === 'get_task') { if (typeof args.task_id !== 'number') return null; frag.appendChild(headLine(tool + ' #' + args.task_id)); return frag; } if (tool === 'list_goals') { if (strText(args.project).length > 0) { bits.push(strText(args.project)); } if (typeof args.goal_id === 'number') { bits.push('#' + args.goal_id); } frag.appendChild(headLine('list_goals', bits)); return frag; } if (tool === 'update_progress' || tool === 'add_comment' || tool === 'save_note') { var text = pickText(args); if (text.length === 0) return null; if (tool === 'save_note' && strText(args.project).length > 0) { bits.push(strText(args.project)); } frag.appendChild(headLine(tool, bits)); frag.appendChild(para(text)); return frag; } if (tool === 'ask_question') { if (question.length === 0) return null; frag.appendChild(headLine('ask_question')); frag.appendChild(para('Q: ' + question, 'event-question')); if (Array.isArray(args.options) && args.options.length > 0) { var list = document.createElement('ul'); args.options.forEach(function (option) { var item = document.createElement('li'); item.textContent = String(option); list.appendChild(item); }); frag.appendChild(list); } node = contentFold('context', args.context); if (node) frag.appendChild(node); return frag; } if (tool === 'submit_plan' || tool === 'submit_rfc' || tool === 'submit_report') { if (strText(args.markdown).length === 0) return null; frag.appendChild(headLine(tool)); node = contentFold('markdown', args.markdown); if (node) frag.appendChild(node); return frag; } if (tool === 'finish') { if (strText(args.summary).length === 0) return null; if (strText(args.status).length > 0) bits.push(strText(args.status)); frag.appendChild(headLine('finish', bits)); node = contentFold('report', args.summary); if (node) frag.appendChild(node); return frag; } if (tool === 'yeehaw') { if (strText(args.message).length === 0) return null; frag.appendChild(headLine(tool)); var whoop = document.createElement('p'); var shout = document.createElement('span'); shout.className = 'yeehaw-message'; shout.textContent = strText(args.message); whoop.appendChild(shout); frag.appendChild(whoop); return frag; } return null; } // Every argument except `skip`, pretty-printed; '' // when nothing else is left to show. function argsWithout(args, skip) { var rest = {}; var keys = Object.keys(args); for (var i = 0; i < keys.length; i++) { if (keys[i] === skip) continue; rest[keys[i]] = args[keys[i]]; } return Object.keys(rest).length > 0 ? JSON.stringify(rest, null, 2) : ''; } function eventNode(evt, callsById) { var row = document.createElement('div'); row.className = 'event-row ' + kindClass(evt.kind); var head = document.createElement('p'); head.className = 'event-head'; var badge = document.createElement('span'); badge.className = 'badge ' + kindClass(evt.kind); badge.textContent = String(evt.kind || '').replace(/_/g, ' '); head.appendChild(badge); var actor = document.createElement('span'); actor.className = 'text-muted'; actor.textContent = ' ' + (evt.actor || '') + ' \u00b7 ' + (evt.created_display || ''); head.appendChild(actor); row.appendChild(head); var body = document.createElement('div'); body.className = 'event-body'; var p = evt.payload || {}; if (evt.kind === 'turn_text' && typeof p.text === 'string') { var think = preLine(p.text); think.className = 'event-pre event-turn'; body.appendChild(think); } else if (evt.kind === 'tool_call') { body.appendChild(toolCallBody(p)); var attached = callsById ? callsById[evt.id] : null; if (attached) { // The outcome rides with its call: the // attached block reads as the output of the // action above it, never a separate row - and // always visible, never behind a click. var mount = document.createElement('div'); mount.className = 'event-attached'; mount.appendChild(resultView(attached, resultEnvelope(attached), true)); body.appendChild(mount); } } else if (evt.kind === 'tool_result') { var envelope = resultEnvelope(evt); body.appendChild(resultView(evt, envelope)); } else if (evt.kind === 'command_output') { // The command the agent ran rides the same // event: prefix it so the output reads as // '$ cmd ... output' instead of bare text. var text = p.text || p.chunk || ''; if (strText(p.command).length > 0) { var cmdPre = preLine('$ ' + p.command); cmdPre.className = 'event-pre event-command'; body.appendChild(cmdPre); body.appendChild(preLine(text)); } else { body.appendChild(preLine(text)); } } else if (evt.kind === 'progress') { var note = document.createElement('p'); note.textContent = p.text || ''; body.appendChild(note); } else if (evt.kind === 'comment') { var quote = document.createElement('blockquote'); if (p.quote) { var q = document.createElement('p'); q.className = 'text-muted'; q.textContent = '> ' + p.quote; quote.appendChild(q); } var cbody = document.createElement('p'); cbody.textContent = (p.author ? p.author + ': ' : '') + (p.body || ''); quote.appendChild(cbody); body.appendChild(quote); } else if (evt.kind === 'yeehaw') { // The agent's celebration: the event-head badge // already carries the yeehaw palette (kindClass -> // .badge-yeehaw); here the message renders in the // bold .yeehaw-message span, never a payload dump. var whoop = document.createElement('p'); var shout = document.createElement('span'); shout.className = 'yeehaw-message'; shout.textContent = strText(p.message); whoop.appendChild(shout); body.appendChild(whoop); } else if (evt.kind === 'usage') { // One glancable line: model, token counts with // separators, cached share, wall clock - not a // key/value list to squint at. The daemon may // report either token count alone. var ubits = []; if (strText(p.model).length > 0) ubits.push(strText(p.model)); var inTok = typeof p.prompt_tokens === 'number' ? fmtInt(p.prompt_tokens) + ' in' : ''; var outTok = typeof p.completion_tokens === 'number' ? fmtInt(p.completion_tokens) + ' out' : ''; if (inTok.length > 0 || outTok.length > 0) { ubits.push(inTok + (inTok.length > 0 && outTok.length > 0 ? ' / ' : '') + outTok + ' tok'); } if (typeof p.cached_tokens === 'number') { ubits.push(fmtInt(p.cached_tokens) + ' cached'); } if (typeof p.elapsed_ms === 'number') { ubits.push(fmtDuration(p.elapsed_ms)); } if (ubits.length === 0) ubits.push('unreported'); body.appendChild(para(ubits.join(' \u00b7 '), 'event-usage')); } else if (evt.kind === 'state_change') { // {from, to, text} from the web's transitions: // the arrow line reads at a glance. var from = strText(p.from); var to = strText(p.to); if (from.length > 0 && to.length > 0) { body.appendChild(para(from + ' \u2192 ' + to)); } else { body.appendChild(para(strText(p.text) || JSON.stringify(p))); } } else if (evt.kind === 'lifecycle') { // {event, detail?} from the daemon's harness // observations. var what = strText(p.event); var detail = strText(p.detail); if (what.length > 0) { body.appendChild(para(detail.length > 0 ? what + ' \u2014 ' + detail : what)); } else { body.appendChild(para(pickText(p) || JSON.stringify(p))); } } else if (evt.kind === 'answer' || evt.kind === 'verdict') { body.appendChild(para(pickText(p) || JSON.stringify(p))); } else { var generic = document.createElement('p'); generic.textContent = p.text || JSON.stringify(p); body.appendChild(generic); } row.appendChild(body); return row; } function turnCard(events, callsById) { var card = document.createElement('div'); card.className = 'turn-card'; var head = document.createElement('p'); head.className = 'turn-head'; if (events[0].payload && events[0].payload.turn !== undefined && events[0].payload.turn !== null) { var chip = document.createElement('span'); chip.className = 'turn-chip'; chip.textContent = 'turn ' + events[0].payload.turn; head.appendChild(chip); } else { var plain = document.createElement('span'); plain.textContent = 'agent turn'; head.appendChild(plain); } var meta = document.createElement('span'); meta.textContent = events.length + ' event' + (events.length === 1 ? '' : 's') + (events[0].session_id ? ' \u00b7 session ' + events[0].session_id : ''); head.appendChild(meta); card.appendChild(head); events.forEach(function (evt) { card.appendChild(eventNode(evt, callsById)); }); return card; } // ---- quiet-call folding ---- // // A side-effect tool whose content already renders as // its own event (update_progress -> progress, // add_comment -> comment, yeehaw -> yeehaw) shows as // that event ALONE when the call succeeded quietly: // the pair of rows around it (the duplicate call and // the bare ack result) folds away. A failure unfolds // both - the call carries the context, the result the // error. The companion must be visible between the // call and its result (the daemon parks it there), or // nothing would show instead. var FOLDABLE_TOOLS = { update_progress: 'progress', add_comment: 'comment', yeehaw: 'yeehaw' }; function quietResult(evt) { var envelope = resultEnvelope(evt); if (envelope.ok === false) return false; if (typeof envelope.exit_code === 'number' && envelope.exit_code !== 0) return false; if (strText(envelope.error).length > 0 || strText(envelope.note).length > 0) return false; if (envelope.timeout) return false; if (strText(envelope.stdout).length > 0 || strText(envelope.stderr).length > 0) return false; var extras = resultExtras(envelope); var keys = Object.keys(extras); for (var i = 0; i < keys.length; i++) { var value = extras[keys[i]]; if (value === true || value === false) continue; if (typeof value === 'string' && value.length === 0) continue; // A row pointer (comment_id, task_id...): the // content it names renders elsewhere. if (typeof value === 'number' && keys[i].indexOf('_id') === keys[i].length - 3) continue; return false; } return true; } // One call per turn: the first tool_result after a call // is its own (or the answer never came yet). The live // wire's result payloads carry no turn of their own - // matching rides on order, tool name and session, with // a turn mismatch (both present) the only rejection. function sameSession(a, b) { return a === b || a == null || b == null; } function matchResultIndex(rows, index) { var evt = rows[index]; var p = evt.payload || {}; if (typeof p.turn !== 'number') return -1; for (var j = index + 1; j < rows.length; j++) { var other = rows[j]; if (other.kind === 'tool_result') { var q = other.payload || {}; if ((q.turn === p.turn || q.turn == null) && sameSession(other.session_id, evt.session_id) && strText(q.tool) === strText(p.tool)) { return j; } return -1; } if (other.kind === 'tool_call') return -1; } return -1; } function foldedQuietCalls(rows) { var folded = {}; rows.forEach(function (evt, index) { if (evt.kind !== 'tool_call') return; var p = evt.payload || {}; if (typeof p.turn !== 'number') return; var companion = FOLDABLE_TOOLS[strText(p.tool)]; if (!companion) return; var resultIndex = matchResultIndex(rows, index); if (resultIndex < 0) return; var hasCompanion = false; for (var k = index + 1; k < resultIndex; k++) { if (rows[k].kind === companion && sameSession(rows[k].session_id, evt.session_id)) { hasCompanion = true; break; } } if (hasCompanion && quietResult(rows[resultIndex])) { folded[index] = true; folded[resultIndex] = true; } }); return folded; } // Rebuild the feed from the loaded window. `mode` // is 'append' (live SSE rows landed at the tail - // stick to the bottom only if the reader was // there), 'prepend' (older history was added // above - keep the viewport on the same rows), or // undefined (first render, filter or search change // - bottom is the reading position, so seed there; // afterwards behave like 'append'). Every branch // captures the scroll state before the rebuild and // only moves the viewport per its rule - a reader // scrolled up into the history is never yanked. function render(mode) { if (!feed) return; var wasAtBottom = atBottom(); var heightBefore = feed.scrollHeight; var scrollTopBefore = feed.scrollTop; while (feed.firstChild) feed.removeChild(feed.firstChild); var visibleRows = state.events.filter(visible); if (visibleRows.length === 0) { var empty = document.createElement('p'); empty.className = 'text-muted log-empty'; empty.textContent = state.events.length === 0 ? 'Waiting for the first events\u2026' : 'No events match the filters.'; feed.appendChild(empty); } // Quiet-call folding + result attachment: a quiet // side-effect pair disappears behind its companion // event; every other call CARRIES its result - the // call row renders the action, the attached block // under it renders the outcome - so the two never // read as separate rows. var folded = foldedQuietCalls(visibleRows); var callsById = {}; var ridesById = {}; visibleRows.forEach(function (evt, index) { if (folded[index] || evt.kind !== 'tool_call') return; var r = matchResultIndex(visibleRows, index); if (r >= 0 && !folded[r]) { callsById[evt.id] = visibleRows[r]; ridesById[visibleRows[r].id] = true; } }); // Grouping: consecutive turn-family events sharing a // session and payload.turn render as one card; every // other event renders inline. Folded rows and rows // riding with their call drop out of the walk. var i = 0; while (i < visibleRows.length) { var evt = visibleRows[i]; if (folded[i] || ridesById[evt.id]) { i++; continue; } var kind = evt.kind; if (kind === 'turn_text' || kind === 'tool_call' || kind === 'tool_result') { var turn = evt.payload ? evt.payload.turn : undefined; var group = [evt]; var j = i + 1; while (j < visibleRows.length) { var next = visibleRows[j]; if (folded[j] || ridesById[next.id]) { j++; continue; } var nextTurn = next.payload ? next.payload.turn : undefined; if ((next.kind === 'turn_text' || next.kind === 'tool_call' || next.kind === 'tool_result') && next.session_id === evt.session_id && (turn === nextTurn || (turn === undefined && nextTurn === undefined))) { group.push(next); j++; } else { break; } } feed.appendChild(turnCard(group, callsById)); i = j; } else { feed.appendChild(eventNode(evt, callsById)); i++; } } // The load-older control rides INSIDE the feed as // its first row - it scrolls away with the history // instead of squatting above the pane - so every // rebuild must re-seat it. if (moreButton) { feed.insertBefore(moreButton, feed.firstChild); moreButton.hidden = state.exhausted; moreButton.disabled = state.loading; moreButton.textContent = state.loading ? 'Loading\u2026' : 'Load older events'; } if (!state.seeded && visibleRows.length > 0) { // First render carrying rows: newest is the // reading position, start pinned to it. state.seeded = true; scrollToBottom(); } else if (mode === 'prepend') { if (wasAtBottom) { scrollToBottom(); } else { // Grow the viewport downward by exactly // the height added above it, so the // rows under the cursor stay put. feed.scrollTop = scrollTopBefore + (feed.scrollHeight - heightBefore); } } else if (wasAtBottom) { scrollToBottom(); } else { feed.scrollTop = scrollTopBefore; } } function onStreamMessage(message) { // Heartbeat frames carry the literal string // "ping" as data; named listeners never fire // because the server sends no event: line. if (message.data === 'ping') return; try { ingest(JSON.parse(message.data)); render('append'); } catch (error) { console.warn('[log] bad event:', error); } } function connect() { var log = statum.read('task_log'); if (!log) return; var base = '/tasks/' + log.task_id; stream = new EventSource(base + '/events/stream'); stream.onmessage = onStreamMessage; stream.onopen = function () { setLive('live'); }; stream.onerror = function () { setLive('retry'); // EventSource retries on its own (retry: 3s // hint); the reconnect replays from the // last seen id and ingest dedupes it. }; if (moreButton) { moreButton.addEventListener('click', function () { loadMore(base); }); } } // Page one window of history older than the oldest // row we hold. The endpoint serves newest-first // within the page; reverse it so ingest prepends in // chronological order, and keep the viewport put. function loadMore(base) { if (state.loading || state.exhausted) return; state.loading = true; render(); var url = base + '/events?before=' + (state.oldest || 0) + '&limit=' + PAGE_SIZE; fetch(url, { credentials: 'same-origin' }) .then(function (response) { return response.json(); }) .then(function (page) { var rows = page.events || []; rows.slice().reverse().forEach(ingest); if (!page.more || rows.length === 0) { state.exhausted = true; } state.loading = false; render('prepend'); }) .catch(function (error) { console.warn('[log] paging failed:', error); state.loading = false; render(); }); } document.addEventListener('click', function (event) { if (!event.target.closest) return; var toggle = event.target.closest('[data-log-filter]'); if (toggle) { var key = toggle.getAttribute('data-log-filter'); state.filters[key] = !state.filters[key]; toggle.classList.toggle('active', state.filters[key]); render(); } }); document.addEventListener('input', function (event) { if (!event.target.closest) return; var search = event.target.closest('[data-log-search]'); if (search) { state.search = search.value.trim(); render(); } }); statum.afterEvaluate(function () { var page = statum.read('task_log'); if (!page || !page.exists) return; feed = document.querySelector('[data-log-feed]'); moreButton = document.querySelector('[data-log-more]'); liveBadge = document.querySelector('[data-log-live]'); liveLabel = document.querySelector('[data-live-label]'); taskStatus = strText(page.task_status); updateBadge(); var header = document.querySelector('[data-log-title]'); if (header) { header.textContent = '#' + page.task_id + ' \u2014 ' + page.task_title; } if (!stream && feed) connect(); render(); }); })(); }