Skip to content

codypendent v0.13.0 (build 142)

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 21 Aug 15:28

v0.13.0

Everything since v0.12.4. Twenty commits, nearly all of them repairs to the two
clients an operator actually sits in front of — the desktop app and the TUI.

The prompt for most of it was blunt: the desktop app was reported as unusable,
with screenshots. What that turned out to mean is recorded below, and it was
not one bug. Pages flickered as fast as the machine allowed, the sidebar could
not be scrolled to the destinations it listed, chat arrived as raw Markdown,
the Providers page was inert, and panels that looked live had silently stopped
receiving anything. Two adversarial review rounds then found the rest.

The minor bump is for two additions: the Agent Skills SKILL.md format is
now a first-class package, and the code graph is drawn rather than counted.
Everything else is a fix.

Screens that could not be used

The councils and repository pages flickered continuously. Eight views take
their loader as a prop and run it from an effect keyed on that prop — and every
one of those loaders is an inline arrow in App.tsx, so it is a new function
on each render. Several also call setState on the app while they run, closing
the loop: render → effect → fetch → app setState → render, as fast as the
machine allows. Fixed with a useLoadOnMount hook that holds the callback in a
ref, rather than by hand-memoizing sixteen call sites, so a future call site
that forgets useCallback costs nothing instead of melting the view. Reverting
this does not merely fail the new test — it kills the vitest worker, which is
the same unbounded loop the screen was showing.

The sidebar could not be scrolled at all. Its nav list is a flex child with
no overflow and the default min-height: auto, which refuses to shrink below
its content — so with groups open it grew past the viewport, pushed the session
list off the bottom of a height: 100vh aside that has no overflow of its own,
and left destinations reachable neither by clicking nor by scrolling.

The chat showed raw Markdown, ## and ** as literal characters. It
renders now, through a small parser rather than a dependency, and the reason is
the security property: it emits React elements and never HTML, so model
output arriving over a socket cannot inject into the webview. A test asserts it
directly — <img src=x onerror=…> survives as visible text and produces no
element. Links render as their text plus the href rather than as a navigable
anchor, because a click target the model chose is not one the operator asked
for.

The Providers page was inert because it was rendered with no onSelect at
all — every click optional-chained into nothing. The credential and model form
already existed one view over, reachable only through Models → Add model; a
chosen provider now opens that flow already on it.

Long system notes fold behind a one-line summary, mirroring the TUI. Empty
lists offer to create the thing they are empty of. The Get Started page no
longer shouts.

Silently doing nothing

These are the ones with no error on screen, which is what made them expensive.

Memory extraction was disabled on every run, for any all-ACP setup. An ACP
entry is a full-agent executor, not a ChatClient, so the extractor refuses
it — and for a models.toml where every entry is ACP, which is the documented
and supported configuration, model selection could never succeed. Extraction
was therefore off since the machine was set up: zero rows across 4,941 events,
with the only evidence a once-per-run log line naming a protocol mismatch
rather than a consequence. Selection now falls back to any other configured
chat-capable model, and when nothing qualifies the warning says what is
actually true.

Loading skills from .claude and .agents registered nothing, and said
nothing about it.
scan_skill_root filters on skill.toml before it looks
at a directory at all, and ecosystem skills carry SKILL.md with YAML
frontmatter and no skill.toml — so every one read as "not a package",
silently, because a package that is never tried produces no failure to report
either. The roots looked right and did nothing.

Analytics exports shipped one page of rows and called it complete. export
asked the query layer for max_rows + 1, but query clamps every caller to
its own 200-row page ceiling — so an export with the default 1,000-row budget
received 200 rows, and the truncation check compared that clamped 200 against
1,000 and was false for every export that could possibly have been truncated.
The artifact recorded 200 rows with truncated: false: a partial dataset
labelled whole, with nothing in it to say which rows were missing.

A gap in the desktop's live event stream was noticed and then forgotten. A
jump in sequence means events the client never received; the reducer detected
it, wrote a console warning and carried on, leaving the transcript permanently
short by that range with nothing marking where. It is now read back from the
durable log.

A stale teardown could kill the new connection, permanently.
daemon_disconnect took whatever connection was registered, with no notion of
which one the caller meant to close, so on a reconnect a deferred teardown
could shut down its replacement. Suppressing the Disconnected frame for a
deliberate disconnect — correct on its own terms — turned that race from a
visible glitch into silent death: the store kept reporting "connected" while
every command timed out.

Live watches did not survive a reconnect. A subscription belongs to the
connection that grew it, and a reconnect builds a new client, so afterwards the
daemon was streaming the open workflow run to nobody. The graph sat at its last
node transition and the blackboard at its last read, indistinguishable from a
run that had gone quiet.

Crashes, wedges and dead ends

A re-issued question crashed the TUI. QuestionAsked replaces a pending
question in place, but the card holding one answer slot per sub-question was
built only when no card existed — so a re-issue with more sub-questions left
the card sized for the previous shape and indexed past the end, panicking the
whole TUI while a question was blocking the operator. Daemon-triggerable.

A question from a closed session wedged the next one. begin_new_session
cleared runs, approvals and the composer but left pending_questions,
question_card_state and pending_prompts behind. The old question captured
the new session's composer, and answering it sent ResolveQuestion against the
new session id, which the daemon rejects — clearing nothing locally. A wedge
with no way out but restarting.

The transcript row counter saturated at 65,535 and hid the newest rows.
RunView::scroll, transcript_max_scroll and the measure pass's row counter
were u16 accumulated with saturating_add. That is reachable: a single model
entry may reach 256 KiB, roughly 3,300 wrapped rows on its own, so twenty of
them saturate the counter and a session holds many runs. Past that point follow
mode pinned to a bottom that was not the bottom and every row beyond became
unreachable — which on screen reads exactly like a hung run. Absolute offsets
are u32 now.

Switching or forking to a session skipped its history restore. Boot pages
the durable log when a catch-up arrives as a compact snapshot; SwitchSession
and ForkSession folded the snapshot alone. The same session opened blank when
reached from inside the TUI and complete when reached at boot, with nothing to
distinguish that from a session that never had a transcript.

An armed remote-UI confirmation outlived the notice announcing it. Arming
set the pending state and showed a notice for about two seconds; the notice
expired and the armed state did not. A stray Enter on the same control an hour
later executed a confirmed action with nothing on screen saying anything had
been armed.

Accessible mode named a command and then refused it. The controls line has
always read "yes or Enter confirms, no or Esc cancels" — but no was never in
the input map and fell through to "unrecognised accessible command". For a
screen-reader user that line is the interface for the dialog. Separately,
yes was reserved as a keypress in every mode, so answering an agent's
question with "yes" submitted the composer's draft instead — normally empty, so
nothing happened and nothing said why.

Authorisation surfaces

Terminal-escape injection reached the two places the operator decides
something.
v0.12.0 fixed this for model prose and stopped there. The approval
modal and the question card render strings the model also chose — program,
arguments, environment, working directory, question text, option labels — and
those reached ratatui raw. Crossterm writes cell symbols verbatim, so a crafted
argument could emit OSC 52 to overwrite the clipboard, or reposition the cursor
and repaint the dialog to describe a command other than the one being approved.
That is the wrong half to have protected: the approval modal is the one place
in the app where the operator authorises something.

Journey stole the approval keys. Approvals now outrank it.

Data integrity

Concurrent preference saves could shred the repository selection. Every
save wrote through the same desktop.json.tmp, so two in flight put their bytes
into one file and both renamed it into place — and truncated JSON loads as "no
repository selected", silently discarding the operator's checkout. AuthStore
in the same tree already namespaces its temp file by pid; this sibling never
inherited it.

A failed key save left a model that looked configured. add_model loaded
auth.json before any write to catch a corrupt store — and then saved the key
after models.toml, so a save that failed on its own terms left the model
listed with no key behind it. The picker showed it as ready and the first
request failed, from an add that reported success. The key is saved first now,
making the only possible partial outcome an inert one.

Leaks, bounds and hot paths

  • A busy host killed healthy UI workers. The watchdog waited four sample
    intervals — one second — for the next process-table sample before declaring
    the accounting mechanism gone and killing the process group. Where there is
    no procfs every sample forks ps and reads the whole process table, and the
    shared sampler serialises that across all watchers, so an ordinarily loaded
    machine tripped it. The deadline is ten seconds; a failed scan and a closed
    channel still fail closed immediately, and CPU stays capped by RLIMIT_CPU.
  • Reconnect leaked a document subscription and four correlation maps.
    Reconnect clears the replica map and keeps the subscription list, so keying
    "already subscribed?" off the replicas appended a duplicate per
    reconnect-then-edit cycle, re-sent in full on every later re-attach. Three
    maps keyed by command ids sent on the retired socket accumulated for the life
    of the process.
  • Snapshot attach held attach_lock across the history download — 500
    events per round trip. Every panel opens its live stream through the same
    lock, so attaching a long session and then opening the workflow or blackboard
    panel hung the panel until the whole history had arrived.
  • The Remote UI renderer rebuilt what had not changed.
    normalize_document deep-cloned and rewrote the entire document tree on
    every paint. Memoized on (document_id, revision) — a sound identity because
    the document store enforces it, refusing a snapshot that reuses a mounted
    revision with a different tree.
  • Accessible mode built a full snapshot per token and discarded it. The
    draw path rendered the whole session into a fresh String before checking its
    refresh budget.
  • A blackboard read that had been superseded could win and put the previous
    run's board under the newer run's selection; WorkflowView already guarded
    exactly this and the sibling had not inherited it.

Additions

SKILL.md packages are first-class. The open Agent Skills format
(agentskills.io) defines a package as SKILL.md plus optional scripts/,
references/ and assets/; all four are now ingested, from .claude/skills
and .agents/skills in the checkout and under $HOME, alongside
.codypendent/skills, including installed plugin marketplace bundles. The
synthesised manifest is deliberately conservative: identity from the directory
slug, version 0.0.0 because the format carries none, and no permissions,
tools or limits claimed — an imported skill gets the empty capability set
rather than inheriting one it never declared, and trust is recorded as unsigned
and unattributed rather than implied.

The code graph is drawn. Everything needed already arrived with
ReadCodeGraphStatus and was rendered as numbers, so the plot is a view of
what the panel already fetched rather than a second query. Composition bars
rather than a node-link diagram, deliberately: a real repository graph runs to
hundreds of thousands of nodes — which is why the inspector beside it is paged
at all — so a layout of the whole thing is not readable, and a layout of a
sampled few hundred is a picture of the sample rather than of the repository.

Known, and deliberately not fixed

The Remote UI renderer's scrolled_child allocates its scratch buffer at the
child's full logical height every frame, so the cost tracks total content
rather than what is visible. Shrinking it means shrinking the painter's clip —
ratatui 0.29 panics on out-of-bounds writes, verified rather than assumed — and
the clip also assigns focus order, so a smaller clip would silently drop
scrolled-off content out of the Tab sequence. Separating "painted" from
"focusable" is a real refactor and is left as one.

The control-plane, web console and VS Code review findings are not in this
release. codypendent-control-plane does not appear in release.yml and is
unshipped, so none of them can reach an installed build; the shipping surfaces
came first.