- `catalogHistory` validated its durable read. `agent.session.events` is a
JSONL/SQLite seed on resume or fork, and seed validation guarantees only a
source object with a non-empty `kind`; a `skill-catalog` record with missing
or wrongly shaped `entries` threw inside the step listener, failing every
later turn of that session. It is now skipped as an unrecognizable record,
the posture the replaced content digest had, with a regression test over six
malformed shapes.
- The headless keyless smoke still filtered catalogs by the old plugin source,
so the `built-bin-smoke` gate would not have found the catalog message.
- Entries record the published description unescaped. The pseudo-XML escaping
belongs to the `<available_skills>` frame and is applied when rendering it,
so a description containing `<` no longer reaches the card as `<`.
`escapeText` is injective, so republish semantics and the model-facing text
are unchanged.
- Adjacent text blocks join with no separator, matching how provider adapters
flatten them; the body no longer shows a line break the model never saw.
- Provenance fields are bounded like the text: an unknown producer may record
an arbitrarily large value.
- Both readers are all-or-nothing, and the row's form marker reports what
rendered rather than what was declared, so a partly unreadable record cannot
present a confident but incomplete account.
- The catalog body consumes `update` as a replacement notice; the digest
canonicalizes per entry as JSON, since every separator character is itself
legal in a description.
- `core.md` documents the form axis with a `ContextForm` type-equiv block, and
both projections assert the wiring they duplicate.
Injected context reached the transcript as one anonymous shape whose
expanded body was the whole message serialized as inline JSON, so the
escaping collapsed the only readable part — the model-facing prose —
into a single line.
`MessageSource` gains an optional producer-declared `form`, a small
semantic vocabulary of information shapes independent of `kind`: kind
says who produced the context, form says what shape it is, so several
producers may share one presentation. Two values ship.
`instructions` (workspace-context) lists the reconciled files above
their text and keeps the `<system-reminder>` framing verbatim, because
the framing is part of what the model read. `catalog` moves dsh-tool-skill
off the shared plugin kind onto a `skill-catalog` source carrying the
published name/description entries, and the body lists those instead of
re-parsing `<available_skills>` out of the prose. Catalog identity moves
with it: the republish digest now covers the durable entries, deleting
the text-slicing that recovered them from a logged message.
Everything else renders the opaque body — the model-facing text with its
real line breaks, then the remaining provenance as fields. That is the
documented default, not a leftover: a resumed, forked, or foreign log
must render whether or not its producer is mounted here, which is why
the classification lives in the durable source rather than a client-side
table keyed by producer.
Resuming a long session (196k events, 2.2k steps, 1.8k tool cards) took
~12s to render and ~800ms to echo one keystroke:
- Every step's timing footer called stepTimingAt, which replayed the whole
event log per footer - O(steps x events) on the initial render.
- pi-tui re-renders every component each frame and relies on per-component
line caches, but ToolCardComponent/ContextCardComponent built throwaway
Text/Markdown instances inside render(width), re-wrapping every settled
card's output on every keystroke.
Replace the per-footer replay with one shared StepTimingTracker per chat
mount (single O(events) cursor over the append-only log), and cache card
rows by width via CardLineCache, dropped by every state mutator and
invalidate().
Measured (tmux 200x50, 196k-event session): resume prompt-ready ~12s -> ~7.6s;
per-keystroke echo ~800ms median -> ~11ms.
The ShellDialect marker on BashExecutor and the load-time rejection in
tool-bash/tool-pwsh force every test and example fake executor to declare
a dialect for a guard with no in-repo or plausible deployment to catch
(shipped compositions always pair the tool with its matching executor),
and they break the example composition suites that stub ctx.bash without
a dialect (agent-spine-demo/cli-demo/acp-demo lost the bash tool).
Keep the non-dialect doc fixes that landed with the attempt: the tool-bash
README bashEnv contract trim with the stale example import removed, the
acp-snapshot suite.ts 'usable pwsh' JSDoc, and the pwsh.cordis.yml comment
indent. The parity note records the attempt and revert under Alternatives.
The seam gains ShellDialect ('bash' | 'powershell' - concrete shells, not
families: zsh or fish would be their own values, never 'bash'); bash-local
declares bash (bash-sandbox inherits), pwsh-local declares powershell, and
both tools throw at load when the mounted executor speaks another dialect -
previously tool-pwsh over bash-local handed PowerShell text to bash -c and
the deployment error surfaced as ordinary nonzero exits. Pinned by mismatch
tests on both tools; the parity note records the contract (both languages).
Also from the review round: the tool-bash README's managed-environment
section becomes a summary linking the owning dsh-bash-env contract (the
duplicated prose carried a stale owner in its example import), the
pwshOnly JSDoc drops the stale 'on PATH' phrasing, and the task-tools
contract comment in the two pwsh compositions is indented into its block.
session-title already registers a title projection unit; /resume now
reads it instead of scanning logs: live rows from the registry
snapshot, persisted rows from the durable checkpoint row
(cachedSnapshot, zero I/O), and only rows without a usable checkpoint
pay a coldSnapshot — checkpoint plus readFrom tail, written back so the
next scan is metadata-only. Cold reads are bounded by the new
resumeScanConcurrency config; compositions without the cache fall back
to the bounded readTitleSnapshots batch. The TUI overlay mounts the
projection registry, storage, and projection-cache rows over the same
storages root the web surface uses, so checkpoints serve both.
The merge resolved the module-graph.md conflict by regenerating doc GRAPHS
(gen-doc-graphs) while this file belongs to gen-module-graph; verify-module-graph
is a static-lane gate outside doc-sync, so the staleness only surfaced on CI.