feat(desktop): DSH Electron desktop shell — harness internals visualized

Minimal Electron shell over the DSH JSON-RPC runtime — a first-look at
what a ChatGPT.app-style host on top of the DeepSeek Harness looks
like, with the harness's normally-invisible internals (trace timeline,
context surface, subagent tree, compaction, plugin registry, rubrics)
brought forward as first-class UI surfaces so plugin authors and
researchers can see what the agent is actually doing.

Runs against three keyless-to-live profiles (stdio-echo works on
master out of the box; daemon-echo / daemon-vibe-echo activate once
the daemon-demo lands; stdio-deepseek and daemon-vibe hit the real
DeepSeek API when you supply a key). HARNESS_DEV auto-resolves to the
in-repo runtime when this shell ships under examples/desktop/, so a
fresh clone launches without config; env DSH_DEV_ROOT overrides for
custom layouts, and a sibling deepseek-harness-dev/ checkout is the
original dev workflow.

Cold-clone gate (P0 fixes for first-time-clone usability):
- HARNESS_DEV: 3-candidate resolver (env → walk-up in-repo marker →
  sibling), unit-tested via mock fs so ordering is locked without
  needing either real layout on disk.
- config yml leaves rewritten at assemble time so the sibling-clone
  paths (../../deepseek-harness-dev/examples/echo-agent/…) become
  the in-repo paths (../../echo-agent/…) in the released tree —
  source yml stays usable for local dev, released tree ships a
  working shape.
- pnpm-workspace.yaml allowBuilds.electron = true (was placeholder).
- missing-key card in stdio-deepseek offers a one-click switch to
  stdio-echo (the keyless profile that works on master) rather than
  daemon-echo (blocked on the not-yet-shipped daemon-demo).
- assemble-oss-release.sh rewrites the source-side breadcrumb name
  'dsh-desktop-demo' → 'dsh-desktop' for the released package.json.

FOUC guard on the onboarding gate (41fc5df carried) keeps the
first-launch splash from flashing before the runtime probe finishes.

Test suite (1634 tests in source, 3990 in the runtime repo) covers
resolver ordering, renderer classifiers, trace timeline shape,
compaction diff rendering, rubric parity, and the missing-key
onboarding paths.
This commit is contained in:
ZiyaZhang
2026-07-18 12:59:34 -07:00
parent 67053d1cf6
commit e8f5c0b51b
378 changed files with 104431 additions and 0 deletions

View File

@@ -0,0 +1,585 @@
// trace-graph.js — Task #203, view C (agent node graph).
//
// Renders a DAG of the trace as SVG: nodes = llm-step / tool / subagent /
// compact events; edges = control flow (sequential order within a step,
// plus fan-out from a spawn_agent tool/call to the started subagent's
// first event).
//
// Layout: hand-rolled BFS-level layering.
// - Nodes are grouped into levels by graph position (parent → child; a
// level is one deeper than its highest-level predecessor).
// - Within a level, nodes are ordered by wire seq so the visual reading
// order matches the transcript.
// - Columns are evenly spaced across level height; rows within a
// column are spaced compact — the reader complaint at Mission Topology
// was "huge whitespace between nodes". We use a tight 44px gap
// between rows and 130px between levels, tuned so a 3-level 6-node
// graph fills a 600×260 viewport comfortably.
//
// Vendoring note: the task suggested elkjs. In this offline development
// environment, elkjs cannot be fetched fresh and vendoring a 1.4MB
// bundle for a demo shell is heavy. The hand-rolled layered layout is
// the documented fallback and is honest about it — this file's
// `layoutMode = 'bfs-layered-fallback'` surfaces in the report.
//
// Interactions: node click → onSeqClick(seq); node hover → mini metadata
// popover (name / duration / tokens if available); wheel-zoom + drag-pan on
// the SVG viewBox. Nodes for still-running events pulse with a border
// animation (respects prefers-reduced-motion via CSS).
'use strict'
;(function () {
// ─── graph construction ──────────────────────────────────────────────────
//
// buildGraph(input) → { nodes, edges, levels, layoutMode }
//
// `input` is a step-record or list. Each interesting event becomes a node.
// Filtering is intentional — we skip assistant/chunk and hook/* rows so the
// graph reads at a glance; the timeline view keeps the full detail.
//
// Node shape:
// { id, seq, family, glyph, label, startTime, endTime|null, tokens|null,
// status: 'ok'|'running'|'err' }
// Edge shape:
// { from, to, kind: 'seq'|'fan-out'|'return' }
const NODE_FAMILY = {
step: { glyph: '●', label: 'step' },
llm: { glyph: '◆', label: 'llm' },
tool: { glyph: '▲', label: 'tool' },
subagent: { glyph: '◉', label: 'subagent' },
compact: { glyph: '◇', label: 'compact' },
fork: { glyph: '⑂', label: 'fork' },
}
function buildGraph(input) {
const records = normaliseRecords(input)
const nodes = []
const edges = []
let prevStepId = null
for (const rec of records) {
if (!rec || typeof rec !== 'object') continue
const stepId = `step:${rec.turn}.${rec.step}:${numericOrNull(rec.startSeq)}`
nodes.push({
id: stepId,
seq: numericOrNull(rec.startSeq),
family: 'step',
glyph: NODE_FAMILY.step.glyph,
label: labelForStep(rec),
startTime: numericOrNull(rec.startTime),
endTime: numericOrNull(rec.endTime),
durationMs: numericOrNull(rec.durationMs),
tokens: rec._stepUsage || null,
status: rec.open ? 'running' : 'ok',
})
if (prevStepId) edges.push({ from: prevStepId, to: stepId, kind: 'seq' })
// Walk this step's events. Only "interesting" families become nodes;
// assistant/chunk + hook/* stay latent (visible in Timeline / Tree).
const events = collectStepEvents(rec)
let prevInStep = stepId
for (const ev of events) {
const family = familyForEventNode(ev)
if (!family) continue
const spec = NODE_FAMILY[family] || NODE_FAMILY.step
const nodeId = `ev:${family}:${numericOrNull(ev.seq)}`
nodes.push({
id: nodeId,
seq: numericOrNull(ev.seq),
family,
glyph: spec.glyph,
label: labelForEventNode(ev, family),
startTime: numericOrNull(ev.time),
endTime: numericOrNull(ev._pairEndTime),
durationMs: durationForEventNode(ev),
tokens: tokensForEventNode(ev),
status: 'ok',
})
edges.push({ from: prevInStep, to: nodeId, kind: 'seq' })
// Subagent fan-out: a spawn_agent tool/call links out to a stub
// "sub" node in the same level column (visual cue that this call
// branches off).
if (family === 'tool' && ev.data && ev.data.name === 'spawn_agent') {
const subId = `sub:${numericOrNull(ev.seq)}`
nodes.push({
id: subId,
seq: numericOrNull(ev.seq),
family: 'subagent',
glyph: NODE_FAMILY.subagent.glyph,
label: 'subagent (spawned)',
startTime: numericOrNull(ev.time),
endTime: null,
durationMs: null,
tokens: null,
status: 'ok',
})
edges.push({ from: nodeId, to: subId, kind: 'fan-out' })
}
prevInStep = nodeId
}
prevStepId = stepId
}
const levels = assignLevels(nodes, edges)
return {
nodes,
edges,
levels,
layoutMode: 'bfs-layered-fallback',
}
}
function collectStepEvents(rec) {
const seen = new Set()
const out = []
for (const list of [rec.events, rec.outputs, rec.inputs]) {
if (!Array.isArray(list)) continue
for (const ev of list) {
if (!ev || typeof ev !== 'object') continue
const key = typeof ev.seq === 'number' ? `s${ev.seq}` : `t${ev.type}|${out.length}`
if (seen.has(key)) continue
seen.add(key)
out.push(ev)
}
}
// Preserve wire order.
out.sort(function (a, b) {
const as = numericOrNull(a.seq)
const bs = numericOrNull(b.seq)
if (as === null && bs === null) return 0
if (as === null) return 1
if (bs === null) return -1
return as - bs
})
return pairToolCallResultInGraph(out)
}
function pairToolCallResultInGraph(events) {
const byCallId = new Map()
const skipSet = new Set()
for (let i = 0; i < events.length; i++) {
const ev = events[i]
if (ev && ev.type === 'tool/call' && ev.data && typeof ev.data.callId === 'string') {
byCallId.set(ev.data.callId, i)
} else if (ev && ev.type === 'tool/result' && ev.data && typeof ev.data.callId === 'string') {
const callIdx = byCallId.get(ev.data.callId)
if (callIdx !== undefined) {
const callEv = events[callIdx]
const resultTime = numericOrNull(ev.time)
if (resultTime !== null) callEv._pairEndTime = resultTime
if (ev.data.isError) callEv._pairIsError = true
skipSet.add(i)
}
}
}
const out = []
for (let i = 0; i < events.length; i++) if (!skipSet.has(i)) out.push(events[i])
return out
}
function familyForEventNode(ev) {
const t = ev && ev.type
if (t === 'assistant/message' || t === 'request/header') return 'llm'
if (t === 'tool/call') return 'tool'
if (t === 'compact/summary') return 'compact'
if (t === 'subagent.started' || t === 'subagent.finished') return 'subagent'
return null
}
function labelForEventNode(ev, family) {
const d = ev.data || {}
if (family === 'tool') {
const name = typeof d.name === 'string' ? d.name : 'tool'
return name
}
if (family === 'llm') {
if (ev.type === 'request/header' && d.header && typeof d.header.model === 'string') {
return d.header.model
}
return 'llm'
}
if (family === 'subagent') return ev.type
if (family === 'compact') return 'compact'
return ev.type || 'event'
}
function durationForEventNode(ev) {
if (!ev) return null
const s = numericOrNull(ev.time)
const e = numericOrNull(ev._pairEndTime)
if (s !== null && e !== null) return e - s
return null
}
function tokensForEventNode(ev) {
const d = ev && ev.data
if (!d) return null
if (ev.type === 'assistant/message' && d.usage) {
const u = d.usage
const inTok = typeof u.inputTokens === 'number' ? u.inputTokens : null
const outTok = typeof u.outputTokens === 'number' ? u.outputTokens : null
if (inTok === null && outTok === null) return null
return { input: inTok, output: outTok }
}
return null
}
// ─── layer assignment ────────────────────────────────────────────────────
//
// assignLevels(nodes, edges) → [[nodeId, nodeId, …], …]
//
// BFS from roots (nodes with no incoming edges). Each node's level is one
// more than the max of its predecessors' levels; this handles the sequence
// spine plus fan-out branches cleanly without a real topological sort.
function assignLevels(nodes, edges) {
const inbound = new Map()
const outbound = new Map()
for (const n of nodes) { inbound.set(n.id, []); outbound.set(n.id, []) }
for (const e of edges) {
if (inbound.has(e.to)) inbound.get(e.to).push(e.from)
if (outbound.has(e.from)) outbound.get(e.from).push(e.to)
}
const level = new Map()
const queue = []
for (const n of nodes) {
if ((inbound.get(n.id) || []).length === 0) {
level.set(n.id, 0)
queue.push(n.id)
}
}
let guard = 0
while (queue.length && guard++ < 10000) {
const id = queue.shift()
const l = level.get(id) || 0
for (const child of outbound.get(id) || []) {
const cur = level.has(child) ? level.get(child) : -1
const next = Math.max(cur, l + 1)
if (next !== cur) {
level.set(child, next)
queue.push(child)
}
}
}
// Any node still missing (shouldn't happen for a DAG, but guard) goes on
// its own trailing row.
let maxLevel = 0
for (const v of level.values()) if (v > maxLevel) maxLevel = v
for (const n of nodes) if (!level.has(n.id)) level.set(n.id, ++maxLevel)
const levels = []
for (let i = 0; i <= maxLevel; i++) levels.push([])
for (const n of nodes) levels[level.get(n.id)].push(n.id)
// Stabilise column order by seq for readable left-to-right reading.
for (const col of levels) {
col.sort(function (a, b) {
const na = nodes.find(function (n) { return n.id === a })
const nb = nodes.find(function (n) { return n.id === b })
const as = na && na.seq !== null ? na.seq : Infinity
const bs = nb && nb.seq !== null ? nb.seq : Infinity
return as - bs
})
}
return levels
}
// ─── DOM rendering ───────────────────────────────────────────────────────
const SVGNS = 'http://www.w3.org/2000/svg'
// Layout constants — tuned for the "no huge whitespace" complaint at
// Mission Topology (the reader kept saying nodes floated too far apart).
const COL_GAP = 140
const ROW_GAP = 44
const NODE_R = 14
const PAD = 30
function renderGraph(doc, input, opts) {
const options = opts || {}
const g = buildGraph(input)
const positions = computePositions(g)
const width = Math.max(400, positions.width)
const height = Math.max(200, positions.height)
const wrap = doc.createElement('div')
wrap.className = 'trace-graph'
wrap.setAttribute('data-layout-mode', g.layoutMode)
const svg = doc.createElementNS(SVGNS, 'svg')
svg.setAttribute('class', 'trace-graph-svg')
const initialViewBox = `0 0 ${width} ${height}`
svg.setAttribute('viewBox', initialViewBox)
svg.setAttribute('width', String(Math.min(width, 900)))
svg.setAttribute('height', String(Math.min(height, 500)))
svg.setAttribute('role', 'img')
svg.setAttribute('aria-label', `Agent graph: ${g.nodes.length} nodes, ${g.edges.length} edges`)
// Edges first so they sit under nodes.
const edgesG = svgGroup(doc, 'trace-graph-edges')
for (const edge of g.edges) {
const from = positions.by.get(edge.from)
const to = positions.by.get(edge.to)
if (!from || !to) continue
const path = svgEl(doc, 'path', {
d: cubicPath(from.x, from.y, to.x, to.y),
class: `trace-graph-edge kind-${edge.kind}`,
fill: 'none',
})
edgesG.appendChild(path)
}
svg.appendChild(edgesG)
const nodesG = svgGroup(doc, 'trace-graph-nodes')
for (const node of g.nodes) {
const pos = positions.by.get(node.id)
if (!pos) continue
const nodeG = svgGroup(doc, `trace-graph-node family-${node.family} status-${node.status}`)
nodeG.setAttribute('data-seq', node.seq !== null ? String(node.seq) : '')
nodeG.setAttribute('transform', `translate(${pos.x}, ${pos.y})`)
if (typeof options.onSeqClick === 'function' && node.seq !== null) {
// -07-17 (D2): the callback was already wired,
// but the node had no visible selected state and no explicit cursor,
// so QA-CDP + reader alike could not tell that a click landed. Give
// the node a `.selected` reflex so the reader has feedback while the
// callback also drives the right-side detail pane. selectNode is
// scoped to `nodesG` so a re-render (view switch) rebuilds cleanly.
nodeG.setAttribute('tabindex', '0')
nodeG.style.cursor = 'pointer'
const selectAndDispatch = function () {
try {
const prev = nodesG.querySelectorAll
? nodesG.querySelectorAll('.trace-graph-node.selected')
: []
if (prev && prev.forEach) prev.forEach(function (n) { n.classList && n.classList.remove('selected') })
} catch (_) { /* jsdom / test doc lacks querySelectorAll — safe */ }
if (nodeG.classList && typeof nodeG.classList.add === 'function') {
nodeG.classList.add('selected')
}
options.onSeqClick(node.seq, node)
}
nodeG.addEventListener('click', selectAndDispatch)
nodeG.addEventListener('keydown', function (ev) {
if (ev && (ev.key === 'Enter' || ev.key === ' ')) {
ev.preventDefault()
selectAndDispatch()
}
})
}
const circle = svgEl(doc, 'circle', {
cx: 0, cy: 0, r: NODE_R,
class: `trace-graph-node-body family-${node.family}`,
})
nodeG.appendChild(circle)
const glyph = svgEl(doc, 'text', {
x: 0, y: 4, class: 'trace-graph-node-glyph',
'text-anchor': 'middle',
})
glyph.textContent = node.glyph
nodeG.appendChild(glyph)
const label = svgEl(doc, 'text', {
x: 0, y: NODE_R + 14, class: 'trace-graph-node-label',
'text-anchor': 'middle',
})
label.textContent = trim(node.label, 20)
nodeG.appendChild(label)
// Tooltip title (native SVG title = hover mini-metadata card).
const title = svgEl(doc, 'title', {})
title.textContent = titleFor(node)
nodeG.appendChild(title)
nodesG.appendChild(nodeG)
}
svg.appendChild(nodesG)
// Pan (drag) + zoom (wheel) via viewBox mutation. Lightweight, no dep.
installPanZoom(svg, width, height)
wrap.appendChild(svg)
return wrap
}
function titleFor(node) {
const bits = [node.label, node.family]
if (node.durationMs !== null) bits.push(formatMs(node.durationMs))
if (node.tokens) {
const parts = []
if (typeof node.tokens.input === 'number') parts.push(`${node.tokens.input}`)
if (typeof node.tokens.output === 'number') parts.push(`${node.tokens.output}`)
if (parts.length) bits.push(parts.join(' '))
}
if (node.seq !== null) bits.push(`seq ${node.seq}`)
return bits.join(' · ')
}
function cubicPath(x1, y1, x2, y2) {
// Horizontal cubic curve — control points at 45% of the horizontal
// distance, so short edges stay taut and long edges bow gently.
const dx = Math.max(30, Math.abs(x2 - x1) * 0.45)
const c1x = x1 + dx
const c2x = x2 - dx
return `M${x1},${y1} C${c1x},${y1} ${c2x},${y2} ${x2},${y2}`
}
function computePositions(g) {
const by = new Map()
const levels = g.levels
let maxRows = 0
for (const col of levels) if (col.length > maxRows) maxRows = col.length
const nodeById = new Map()
for (const n of g.nodes) nodeById.set(n.id, n)
for (let i = 0; i < levels.length; i++) {
const col = levels[i]
const x = PAD + i * COL_GAP + NODE_R
const totalH = col.length * ROW_GAP
const startY = PAD + Math.max(0, (maxRows - col.length) * ROW_GAP / 2)
for (let j = 0; j < col.length; j++) {
const y = startY + j * ROW_GAP + NODE_R
by.set(col[j], { x, y, level: i, row: j })
}
}
const width = PAD * 2 + levels.length * COL_GAP + NODE_R
const height = PAD * 2 + maxRows * ROW_GAP + NODE_R + 22
return { by, width, height }
}
// Pan (mouse drag on background) + zoom (wheel on SVG) with viewBox math.
// Keeps interactions local to the SVG so it composes inside any container.
function installPanZoom(svg, initialW, initialH) {
let vb = { x: 0, y: 0, w: initialW, h: initialH }
let dragging = null
svg.addEventListener('wheel', function (ev) {
ev.preventDefault()
const scale = ev.deltaY > 0 ? 1.12 : 1 / 1.12
// Zoom around the SVG center (approx — no client-rect math to keep
// the module dep-free).
const cx = vb.x + vb.w / 2
const cy = vb.y + vb.h / 2
vb.w *= scale
vb.h *= scale
vb.x = cx - vb.w / 2
vb.y = cy - vb.h / 2
if (vb.w < 60) { vb.w = 60; vb.h = 60 * (initialH / initialW) }
if (vb.w > initialW * 6) { vb.w = initialW * 6; vb.h = vb.w * (initialH / initialW) }
svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`)
}, { passive: false })
svg.addEventListener('mousedown', function (ev) {
if (ev.target !== svg && !ev.target.classList.contains('trace-graph-edges')) return
dragging = { x: ev.clientX, y: ev.clientY, vb: { x: vb.x, y: vb.y } }
svg.style.cursor = 'grabbing'
})
svg.addEventListener('mousemove', function (ev) {
if (!dragging) return
const sx = vb.w / (svg.clientWidth || initialW)
const sy = vb.h / (svg.clientHeight || initialH)
vb.x = dragging.vb.x - (ev.clientX - dragging.x) * sx
vb.y = dragging.vb.y - (ev.clientY - dragging.y) * sy
svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`)
})
svg.addEventListener('mouseup', function () { dragging = null; svg.style.cursor = '' })
svg.addEventListener('mouseleave', function () { dragging = null; svg.style.cursor = '' })
}
// Export SVG string with an inline stylesheet so it opens legibly outside
// the app.
function exportGraphSVG(doc, input, opts) {
const el = renderGraph(doc, input, opts)
const svg = el.querySelector('svg')
if (!svg) return ''
const style = doc.createElementNS(SVGNS, 'style')
style.textContent = GRAPH_EXPORT_STYLE
svg.insertBefore(style, svg.firstChild)
return svg.outerHTML
}
const GRAPH_EXPORT_STYLE = `
.trace-graph-svg { font: 11px system-ui, sans-serif; background: #fff; }
.trace-graph-edge { stroke: #98989d; stroke-width: 1.2; }
.trace-graph-edge.kind-fan-out { stroke: #d0a97b; stroke-dasharray: 3 3; }
.trace-graph-node-body { stroke: #1d1d1f; stroke-width: 1.2; }
.trace-graph-node-body.family-step { fill: #e6e7ea; }
.trace-graph-node-body.family-llm { fill: #d9c8f3; }
.trace-graph-node-body.family-tool { fill: #f0c88a; }
.trace-graph-node-body.family-subagent { fill: #eecfa1; }
.trace-graph-node-body.family-compact { fill: #f5a97b; }
.trace-graph-node-glyph { font-weight: 600; fill: #1d1d1f; font-size: 12px; }
.trace-graph-node-label { font-size: 10px; fill: #1d1d1f; }
`
// ─── shared helpers ──────────────────────────────────────────────────────
function normaliseRecords(input) {
if (Array.isArray(input)) return input
if (input && typeof input === 'object') return [input]
return []
}
function numericOrNull(v) {
return typeof v === 'number' && Number.isFinite(v) ? v : null
}
function labelForStep(rec) {
const t = numericOrNull(rec.turn)
const s = numericOrNull(rec.step)
if (t !== null && s !== null) return `step ${t}.${s}`
if (s !== null) return `step ?.${s}`
return 'step ?'
}
function formatMs(ms) {
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return ''
if (ms < 1000) return `${Math.round(ms)}ms`
return `${(ms / 1000).toFixed(1)}s`
}
function trim(s, n) {
if (typeof s !== 'string') return ''
const c = s.replace(/\s+/g, ' ').trim()
if (c.length <= n) return c
return c.slice(0, n - 1) + '…'
}
function svgEl(doc, tag, attrs) {
const el = doc.createElementNS(SVGNS, tag)
if (attrs) {
for (const k of Object.keys(attrs)) el.setAttribute(k, String(attrs[k]))
}
return el
}
function svgGroup(doc, cls) {
const g = doc.createElementNS(SVGNS, 'g')
if (cls) g.setAttribute('class', cls)
return g
}
// ─── exports ─────────────────────────────────────────────────────────────
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
buildGraph,
assignLevels,
computePositions,
familyForEventNode,
labelForEventNode,
renderGraph,
exportGraphSVG,
NODE_FAMILY,
}
}
if (typeof window !== 'undefined') {
window.__dshTraceGraph = {
buildGraph,
assignLevels,
computePositions,
familyForEventNode,
labelForEventNode,
renderGraph,
exportGraphSVG,
NODE_FAMILY,
}
}
})()