Debug TermCanvas restore, agent sync, and rendering stability
Use this workflow to identify whether the failing layer is persisted agent identity, restore ordering, renderer polling, terminal geometry, xterm rendering, or PTY/tmux I/O before changing lifecycle code.
Failure pattern: A release relaunch creates and saves duplicate terminal views, sometimes on the wrong canvas, when agent sync races partial session hydration. Related paths include folderless projects being skipped and periodic renderer work changing geometry or exhausting WebGL contexts.
Verified by: Normalizing a real polluted release snapshot reduced 20 nodes
to 15 unique nodes; an isolated Electron relaunch restored all 15 with
duplicateIdentities: []. npm run build passed and all 249 tests passed. Prior
checks also reproduced the folderless sync mismatch, improved scoped polling
from about 0.55s to 0.20s, held 10-terminal WebGL use to 8, and preserved tail
mount height 486.4609375 and terminal size 116x27. The deleted-session path
was verified by restoring two exact OpenCode session IDs, rebuilding their
parent chain, and observing both running tmux sessions materialize in TermCanvas.
Procedure
1. Diagnose release-relaunch duplicates from the saved session
Quit or isolate the release app before drawing conclusions from changing state. Inspect its persisted session first:
jq ' [.canvases[] as $canvas | $canvas.terminalNodes[]? | { canvasId: $canvas.id, canvasProjectTag: $canvas.agentProjectTag, sessionKey, tmuxSessionName, managedProjectTag, managedAgentName }] as $nodes | { bySessionKey: ($nodes | group_by(.sessionKey) | map(select(.[0].sessionKey != null and length > 1))), byTmuxSession: ($nodes | group_by(.tmuxSessionName) | map(select(.[0].tmuxSessionName != null and length > 1))), byProjectAgent: ($nodes | group_by([.managedProjectTag,.managedAgentName]) | map(select(.[0].managedProjectTag != null and .[0].managedAgentName != null and length > 1))) } ' "$HOME/Library/Application Support/TermCanvas/app-session.json"Some development or older bundles use a lowercase
termcanvasdirectory; confirm the running app's ElectronuserDatapath rather than assuming case.Treat
sessionKey,tmuxSessionName, and the pair(managedProjectTag, managedAgentName)as connected identity edges. Deduplicate globally across all canvases, not once per canvas and not independently per field: transitive matches still describe one terminal view identity.Prefer the correctly owned node within each connected component:
managedProjectTagmatches its canvasagentProjectTag;- its tmux name is canonical:
termcanvas-${sessionKey}; - only then use stable input order as the tie-breaker.
Put this policy in
session_snapshot.jsnormalization so every restore sees a safe snapshot. Test normalization on a copy; do not hand-edit live user data while Electron can save over it.Trace
restoreCanvasSession()andscheduleCanvasAgentSync(). The root race is agent polling while canvases/nodes are only partially hydrated:- set
isSessionHydratingfor the entire restore; - while it is true,
scheduleCanvasAgentSync()must not start reconciliation; - finish creating every restored canvas and node;
- clear the flag in a guaranteed finalization path;
- schedule agent sync exactly once after hydration completes.
Otherwise agent sync can materialize an agent before its saved node is restored, then session persistence turns the temporary duplicate into durable pollution—possibly under the canvas active during the partial restore.
- set
In
reconcileCanvasAgentProject(), index every existing node by tmux session, including already-managed nodes. Matching tmux only for unmanaged nodes misses stale or duplicate managed views and creates another node.Never kill tmux to repair this failure. The duplicate records are renderer views that can share the same long-lived session; normalization should discard extra views while leaving the underlying shell alive.
2. Classify periodic cadence before blaming tmux
Reproduce with enough visible terminals to expose the issue. Record whether the symptom repeats near either fixed renderer interval:
NODE_TAIL_REFRESH_INTERVAL_MS = 4000CANVAS_AGENT_SYNC_INTERVAL_MS = 6000
If a redraw or stall follows either cadence, inspect
renderer.jstimers, DOM writes, layout,ResizeObserver, and xterm fitting first. Terminal output routing is not the default suspect when the symptom is periodic without new PTY output.Across one tick, capture for an affected node:
terminalMount.getBoundingClientRect().heightterminal.colsandterminal.rows- whether
tail.hiddenchanged - whether
ResizeObserver,FitAddon.fit(), orresizeTerminal()ran
A changing mount height links renderer layout to tmux/TUI redraws.
3. Keep the tail slot geometrically stable
Inspect
.terminal-node-tailinstyles.css. The tail participates in a flex column, so hiding it must not remove it from layout. Reserve a fixed slot:.terminal-node-tail { flex: 0 0 1.35rem; height: 1.35rem; } .terminal-node-tail[hidden] { display: block; visibility: hidden; }Do not use the browser default
[hidden] { display: none; }for this element. Removing and restoring the tail changes the terminal mount height, which can triggerResizeObserver -> FitAddon -> terminal:resize -> tmux/TUI redraw.In
updateNodeTailLine(), settextContent,title, orhiddenonly when the next value differs. Keep quiet state implicit; do not write a changing quiet-duration label on every four-second tick.Verify a hidden-to-visible tail transition leaves mount height and xterm rows/columns unchanged. A redraw that merely makes the text look better is not proof that geometry is stable.
4. Treat WebGL contexts as a bounded resource
When text becomes scrambled, incomplete, or unreadable only after many terminals are visible, inspect active xterm renderers before changing PTY or tmux code. Chromium can evict WebGL contexts under pressure, invalidating xterm glyph atlases.
Keep
MAX_TERMINAL_WEBGL_RENDERERSconservative; this checkout uses 8. Count attached addons across all canvases, attach WebGL only to connected, visible nodes on the active canvas, and let terminals beyond the budget use xterm's DOM renderer.For each terminal attachment, make WebGL failure sticky:
- initialize
isWebglRendererDisabledtofalsewhen binding a fresh xterm; - on context loss, set it to
true, detach/dispose the addon, and refresh; - on addon construction/load failure, set it to
trueand remain on DOM; - never reattach WebGL for that attachment after either failure.
This prevents a loss/re-attach loop. A newly bound xterm attachment may try WebGL again if the global budget has room.
- initialize
In an isolated multi-terminal Electron check, assert the WebGL count never exceeds the budget and verify every remaining terminal still renders through DOM. The observed 10-terminal check reported
webglCount: 8for budget 8.
5. Make periodic agent sync a no-op when nothing changed
setTerminalNodeStatus()andsyncTerminalMeta()should compare current text, title, dataset, and ARIA values before writing. Rewriting equal strings still creates needless renderer work when multiplied across terminals.In
syncManagedNodeState(), compare the previous managed state with the incoming snapshot and return whether it changed. Only schedule edge rendering when graph identity changes.In
reconcileCanvasAgentProject():- index existing nodes once by agent name and tmux session;
- create/remove nodes in a batch;
- redraw edges only when the node set, project tag, or edge set changed;
- schedule attention/session persistence only when nodes, graph, or managed state changed.
A quiet six-second sync must not rewrite node chrome, edges, or app-session state.
At the CLI layer,
agentmux ls --project <project-tag> --jsonmust refresh only that project's sessions before querying tmux. Do not refresh every stored project and filter afterward.
6. Diagnose a missing agent from live persisted state
On macOS, inspect the live canvas and renderer snapshots first:
jq '.canvases[] | {id,name,workspace,agentProjectTag,terminalNodes}' \ "$HOME/Library/Application Support/termcanvas/app-session.json" jq . "$HOME/Library/Application Support/termcanvas/canvas-snapshot.json"workspace: nullis valid whenagentProjectTagis persisted.Query the app-scoped database before assuming spawn failed:
sqlite3 -header -column \ "$HOME/Library/Application Support/termcanvas/agentmux/agentmux.db" \ "select name,project,tmux_session,state,agent_state,json_extract(metadata_json,'$.parent_agent') as parent from sessions order by created_at desc;"If the child record exists under the canvas project, creation succeeded and the defect is downstream: sync gating, IPC, or renderer reconciliation.
Confirm database identity inside the managed terminal by inspecting
AGENTMUX_HOME,AGENTMUX_BIN, andAGENTMUX_PROJECT. The main service, terminal shell, restored tmux environment, and vendored wrapper must resolve the same app-scopedAGENTMUX_HOME. Do not mistake a legacy or development database for the packaged app database.Trace the sync gate in order:
renderer.js -> syncActiveCanvasAgentProject()syncs when either a persisted project tag or workspace root exists.main_agentmux_service.js -> syncCanvasProject()accepts an explicit tag without a workspace; it derives a tag only when no tag was supplied.reconcileCanvasAgentProject()matches by agent name or tmux session, creates missing nodes, and detaches stale PTY views without killing tmux.
7. Recover an accidentally deleted OpenCode terminal
First establish the failure boundary without changing live state:
"$AGENTMUX_BIN" ls --json tmux list-sessions -F '#{session_name}'A forced agentmux delete removes the record, events, graph edges, and tmux session. If all are gone, tmux reattachment is impossible, but OpenCode's native conversation may still survive independently.
Query OpenCode's durable session database by title, directory, and recency:
sqlite3 -json "$HOME/.local/share/opencode/opencode.db" ' SELECT id, title, directory, model, datetime(time_updated / 1000, "unixepoch", "localtime") AS updated_at FROM session WHERE lower(title) LIKE "%search terms%" OR lower(directory) LIKE "%project fragment%" ORDER BY time_updated DESC LIMIT 30; 'Do not select by a vague title alone. Match the working directory and recent activity, then inspect the earliest TermCanvas briefing to recover the exact agent name, project tag, and original parent:
sqlite3 -json "$HOME/.local/share/opencode/opencode.db" ' SELECT s.id, s.title, substr(json_extract(p.data, "$.text"), 1, 600) AS briefing FROM session s JOIN part p ON p.session_id = s.id WHERE s.id = "<session-id>" AND json_extract(p.data, "$.type") = "text" AND json_extract(p.data, "$.text") LIKE "[TermCanvas] You are agent %" ORDER BY p.time_created ASC LIMIT 1; 'Restore parents before children using the same app-scoped
AGENTMUX_HOME. Reuse the model provider/id stored in the OpenCode row; the variant remains part of the native session:"$AGENTMUX_BIN" new \ --harness opencode \ --agent <original-agent-name> \ --workdir <original-directory> \ --project <original-project-tag> \ --model <provider/model> \ --session <session-id> \ --no-briefing "$AGENTMUX_BIN" reparent <restored-agent> --parent <restored-or-live-parent>--no-briefingavoids injecting a new task into the recovered conversation. Reparenting restores lineage metadata, depth, and the spawn edge.Verify all three layers before reporting recovery:
"$AGENTMUX_BIN" ls --project <project-tag> --json tmux has-session -t <restored-tmux-session> jq '[.canvases[].terminalNodes[] | select(.managedAgentName == "<agent-name>")]' \ "$HOME/Library/Application Support/TermCanvas/app-session.json"The agentmux row must show the exact
external_session_id, expected parent and depth, the tmux session must exist, and the renderer snapshot must contain the restored node. If the native OpenCode session is absent, stop: recreating a new conversation is not recovery and requires the user's direction.
8. Probe xterm correctly
Do not use
terminalMount.textContentto decide whether terminal output arrived. WebGL xterm draws to a canvas, so DOM text may be empty while the PTY, xterm buffer, and visible terminal are healthy.Read
terminal.buffer.activeline by line:const buffer = nodeRecord.terminal?.buffer?.active; const lines = []; for (let row = 0; buffer != null && row < buffer.length; row += 1) { const line = buffer.getLine(row); if (line != null) lines.push(line.translateToString(true)); } const terminalText = lines.join("\n");Use this for Electron smoke assertions and live-echo diagnostics regardless of renderer type.
9. Verify the relevant path, then the suite
Run targeted regressions first:
node --test test/terminal-rendering-stability.test.js \ test/main-agentmux-service.test.js \ test/agentmux-hierarchy.test.js \ test/terminal-header-title.test.js \ test/session-snapshot.test.jsThen run:
npm run build npm testIn Electron, verify separately:
- start from a copied polluted snapshot and record its raw/unique counts;
- relaunch twice and confirm the restored count stays at the unique count;
- report
duplicateIdentities: []across session, tmux, and project-agent identity keys, including cross-canvas matches; - verify correctly owned nodes stayed on their project canvas;
- confirm every retained terminal still attaches to its existing tmux session;
- verify live echo in
terminal.buffer.activeand cwd restore/switch behavior; - confirm 10 visible terminals do not exceed the WebGL budget;
- confirm a tail transition preserves mount height and cols/rows;
- confirm a persisted child materializes on a folderless canvas after sync.
If a later smoke assertion fails outside these checkpoints, report it separately; do not erase the evidence from the path already exercised.
Gotchas
- Duplicate nodes are renderer-state corruption, not necessarily duplicate shell processes. Preserve tmux unless a user explicitly asks to destroy the session.
- Deduplication must span all canvases. Per-canvas cleanup leaves wrong-canvas copies alive and allows them to be saved again.
- A blind "first node wins" rule can keep the wrong owner. Prefer project ownership and canonical tmux identity before falling back to source order.
- Session hydration is a transaction boundary for renderer reconciliation. A zero-delay scheduled sync is still unsafe if restore has not finished.
- An empty agentmux database does not clear the saved app session. Duplicates can
already be durable in
app-session.jsoneven when no live agents are listed. - A visible terminal is not necessarily DOM-rendered. Empty mount text is not an I/O failure under WebGL.
- A one-row fit change is enough to resize tmux and make a full-screen TUI redraw. Small layout shifts matter when they repeat across many terminals.
hiddenchanges both visibility and layout by default. Override layout only for elements whose slot must remain reserved.- WebGL fallback must be per attachment and sticky after context loss; otherwise periodic visibility/fitting work can recreate the failure loop.
- A missing parent node does not invalidate a surviving child agent. Preserve its parent metadata and place it independently if necessary.
- Renderer nodes are views. Detaching a stale view must not destroy its long-lived tmux session.
- A forced agentmux delete deliberately kills tmux and deletes its own events, but it does not delete OpenCode's native session database. Search that durable store before concluding an OpenCode conversation is lost.
What didn't work
- Deleting duplicate tmux sessions. It is destructive and targets the shared shell instead of the extra renderer views.
- Indexing tmux identities only for unmanaged nodes. Already-managed stale or duplicate views then evade reconciliation and another view is created.
- Inspecting an empty live agentmux database alone. It misses duplicates already
persisted in
app-session.json, which restore can recreate without DB rows. - Reading smoke
firstTerminalTextfromterminalMount.textContentand treating an empty string as failed PTY I/O. WebGL rendered to canvas while the xterm buffer contained the live echo. - Treating all periodic activity as tmux or terminal-output routing without inspecting the four-second tail timer, six-second sync timer, and flex layout.
- Forcing fullscreen, fit, refresh, or redraw. It temporarily masks bad geometry or a damaged context but leaves the trigger intact.
- Assuming a missing canvas node means the agentmux database is missing. The live child record existed; a folderless renderer gate skipped synchronization.
- Refreshing every stored agent before filtering by project, which makes one canvas pay tmux polling cost for unrelated projects.
- Trying to recover a force-deleted agent from tmux or agentmux events. Both were
deleted by design; the surviving OpenCode
sessionrow and its TermCanvas briefing held the recoverable identity and hierarchy. - Restoring from
app-session.jsonalone. It retained node identity and titles but not the native OpenCode session ID needed to resume the conversation.