* perf: paint Today before indexing TUI history
* perf(tui): serve cached periods before source refresh
* fix(tui): keep cached snapshots out of parse memo
* fix(tui): preserve provider and period truth
* fix(tui): label visible period while indexing
* fix(tui): recover provider switches and empty Today
The rebase spliced HEAD's damaged-record tests and the desktop branch's --json status tests into one block; rebuilt as the full HEAD suite plus the desktop 'sync auto status --json' describe block. 30 tests, no duplicate titles.
InstallFlow modal now properly handles error states and prevents action resubmission:
1. When install starts, disable Cancel button (footer shows Cancel-only, not clickable alongside spinner)
2. When CLI call fails or times out, transition to error state showing CLI stderr verbatim in scrollable pre block
3. Error state offers Back (returns to step 1, clears error) and Cancel (closes modal) actions
4. Promise rejection properly updates error state on bridge envelope errors
5. Success path transitions to step 3 without showing error state
Added component tests for:
- Error state displays CLI message verbatim
- Back button clickable during error
- Install button disabled while pending (Cancel-only visible)
Part 1: Add --json output flags to three CLI commands for machine-readable output
- codeburn plugin list --json: array of plugin objects with status and capabilities
- codeburn plugin info <name> --json: manifest with dir and onDiskSections
- codeburn sync auto status --json: status object with configured, accepted, killed, receipts
Part 2: Implement Electron bridge with read and mutation handlers
- Read handlers: pluginList, pluginInfo, syncAutoStatus (return parsed JSON)
- Mutation handlers: pluginAdd, pluginRemove, pluginVerify, syncAutoEnable, syncAutoDisable
- All handlers follow existing Envelope pattern with proper error handling
- Special case: syncAutoEnable captures disclosure text when accept=false
Part 3: Renderer UI components (minimal implementation D1-D3 shell)
- Add Plugins section to nav rail at bottom (quiet entry, plugin icon)
- Implement PluginsSection component with list rendering for loaded/rejected plugins
- Update Section type and routing to include plugins navigation
- Add test coverage for Plugins panel row states
- Implemented as dormant Team tab registry placeholder for future plugin sections
Tests: All CLI tests (3839 passed), app tests (613 passed), E2E gates all passing
- CLI: tsc clean, vitest passes with 26 pre-existing jsdom errors
- App: typecheck clean, npm test green, npm run build succeeds
Independent adversarial review found the remote-install tar validation was blind to symlinks - tar -tzf hides the link target, so a symlink (or the classic symlink-then-write-through pair) could reach the extraction step before verifyPlugin's post-extract symlink check, escaping tempDir on any tar that follows links mid-archive (macOS bsdtar refuses, GNU tar historically does not). Validation now lists with -tvzf and rejects any entry whose type flag is not a plain file or directory, platform-independently. Also bounds exporter per-call iteration to the batch size as defense beyond the existing 8MB stdout cap.
Deep-review probes proved three crash paths where a plugin could kill the entire push: an exporter exiting before reading stdin raised an unhandled EPIPE stream error; exporter stdout of a bare JSON string passed the null check and threw in enrichment; and a call record without a tools array crashed the turn-context build inside the promise executor. All three now cost only that plugin's contribution, with a stderr notice, pinned by regression tests mirroring the probes. Remote install additionally caps the manifest read at 64 KB.
Two fixes from a deep review of the consent scheduler.
The LaunchAgent plist was being written with process.argv[1] as the sole
ProgramArguments entry, which is a non-executable .ts/.js path under
plain node and a stale copy under packaged Electron. buildLaunchAgentPlist
now takes both runtimePath and scriptPath, emits them as separate
<string> entries, and sets ELECTRON_RUN_AS_NODE so the packaged Electron
binary behaves as a node runtime. Both paths are XML-escaped before being
dropped into the plist so a stray & or < in a path cannot break it. The
non-darwin crontab hint now prints the same two-argument form. Caller in
src/sync/cli.ts passes process.execPath and process.argv[1].
When config.auto.accepted is present but missing fingerprint or
acceptedAt, sync auto status used to interpolate the missing field and
print the literal 'undefined'. It now detects the malformed record, prints
a single line telling the user to re-run sync auto enable, and exits non
zero. The run command already fails closed on this case and is unchanged.
localeCompare is locale/ICU dependent and fed the signed digest, so a plugin signed on one machine could false-reject on another (live even in ASCII: README.md vs codeburn-plugin.json order flips between locale-aware and codepoint comparison). All four sort sites now use plain codepoint order, commented as a canonicalization requirement. The sign script now throws on unreadable files instead of signing an incomplete list; the loader keeps skipping (its omission causes a digest mismatch and rejection, the fail-closed direction). New test drives the real sign script over a tree with mixed-case and non-ASCII names, so test and production canonicalization cannot diverge again.
- Validate tarball entries with tar -tzf BEFORE extraction to prevent path traversal
- Reject entries that: start with /, contain .., start with ~, or contain \
- Prevents malicious/compromised servers from writing outside temp dir
- Test: create malicious gzipped tar with ../evil.txt entry and verify rejection
- Extraction only proceeds after successful validation
- Dispatch plugin add <source> to local or remote flow based on path-like vs plugin-name pattern
- Remote flow: readSyncConfig, refresh OIDC token, fetch manifest with sha256, download tarball with integrity verification (50MB limit)
- Extract via system tar to temp dir, detect single top-level dir or files at root
- Verify manifest name matches requested name, hand to shared verify+install path
- Refactor verify+install into reusable helper for both local and remote flows
- Add tests for happy path, sha256 mismatch, manifest 404, no sync config
- Secure token refresh, sha256 verification on both manifest and download header
ponytail: no retries or resume; failed download just reruns
- Add buildTurnContextMap() helper to compute turn context (turnId, category, retries, hasEdits, oneShot) for calls grouped by session
- Include optional turn field in exporter stdin JSON when call is resolvable to a turn
- Create CODEBURN_PLUGIN_STATE_DIR (~/.config/codeburn/plugin-state/<plugin-name>) before spawning exporters
- Pass CODEBURN_PLUGIN_STATE_DIR to exporter processes; plugins write state outside their signed tree
- Add tests for turn context inclusion, state directory creation, and calls without turns
Per-call attributes and span attributes now pass through filterPluginAttributes to enforce the #1128 sanitizer, rejecting credential/path-like stringValues and dropping non-scalar values (array, object). Fixes security regression where raw strings bypassed sanitization. When multiple plugins contribute attributes for the same deduplicationKey, they now merge into one list instead of overwriting, enabling multi-plugin enrichment of the same call. Tests added for sanitization of path-like values, rejection of arrayValues, and merging across plugins.
Loaded plugins with exporters/sync.mjs now enrich sync pushes with per-call attributes and declared extra spans (CB-3 phase 2). CLI spawns exporter child processes, passes batch calls via stdin, collects JSON output with perCall Map and extra spans array. All enrichment is guarded: perCall attrs attached to matching deduplicationKey, extra spans filtered by declared spanKinds, attributes filtered by declared syncAttributes, core keys never overwritten. Exporter timeout (30s), size cap (8MB stdout), and failures are handled gracefully - plugin contributes nothing on crash/timeout/bad JSON, stderr notice printed, push completes. Extra spans are capped at 2x calls.length and >64KB spans dropped. No ledger on CLI side for extra spans (plugin's responsibility). Dry-run shows exporter contributions. Wire is byte-identical when no exporters present.
Closes security hole: plugin commands (executable .mjs files under commands/) are now part of the signed digest, preventing tampering. getPluginFilesList and sign-plugin.mjs getFilesList now walk directories recursively with relative POSIX paths, hashing all regular files while excluding codeburn-plugin.sig (the signature itself) and sections/ (runtime-mutable plugin output). plugin add copies the full tree recursively, preserving directory structure including commands/ and commands/subdir/*.mjs. Installed plugins verify correctly without CODEBURN_PLUGIN_DEV. checkForSymlinks now recurses to catch symlinks anywhere in the tree. Sections may change at runtime without breaking verification because they are excluded from the canonical digest.
Loaded, signature-verified plugins declaring capabilities.commands now register as top-level CLI commands that spawn the plugin's command entry file as a child process. New function registerLoadedPluginCommands(program, loads?) in src/plugins/cli.ts handles collision detection (built-ins win), missing entry file detection with clean error reporting, and exit code propagation. Tests cover all five scenarios: successful invocation, child exit code propagation, missing file handling, collision detection, and rejection filtering. Integrated into src/main.ts else branch to wire plugin commands into the CLI. E2E test (non-dev environment with real signing key) confirms end-to-end plugin invocation works.
Reused menubar-installer pattern for schedule installer. On non-darwin, prints crontab line to add manually. Auto block is optional in sync config; add-only and never migrates existing installs.
Fixes critical bug where keygen() used random keyId but sign() derived it from sha256 of public key PEM. This caused embedded release key (f40248d0) to not match actual derived id (499923ae), breaking plugin verification.
Now both keygen and sign derive keyId consistently from sha256(publicKeyPem).slice(0,4).hex.
Added tests:
- keygen and sign derive identical keyIds from same ephemeral key
- RELEASE_PUBLIC_KEYS map keys match their derived keyIds from embedded PEM values
End-to-end verified: signing with private key and verifying with embedded public key now works without CODEBURN_PLUGIN_DEV.
Ships ed25519 signature verification for plugins with two new components:
- src/plugins/keys.ts: RELEASE_PUBLIC_KEYS map with one release keypair
- scripts/sign-plugin.mjs: keygen and sign commands for plugin developers
- verifyPlugin() in loader.ts: validates ed25519 signatures, rejects unsigned plugins unless CODEBURN_PLUGIN_DEV=1
- plugin add <path>: installs signed plugins to ~/.config/codeburn/plugins/
- plugin remove <name> --confirm: removes installed plugins
All three gates pass: tsc clean, vitest zero new failures, smoke test complete.
Design decision: public keys stored as base64-encoded PEM format rather than raw 32-byte keys. Node.js crypto.verify requires PEM/DER format or KeyObject for ed25519; raw bytes alone fail. PEM is standard and portable.
Private key written to: /tmp/codeburn-signing/codeburn-signing-key.pem
Teams issue #3 introduces the codeburn plugin socket: an opt-in escape
hatch for third parties to add sync attributes, payload sections, and CLI
commands without touching the codeburn CLI source tree. This commit ships
the first half (CB-3) — the manifest layer, the wire guard, and the user-
facing inspector. Release-key signing (the 9b seam) lands separately.
Why split now:
- The wire guard (filterPluginAttributes in sync/otlp.ts) is the load-
bearing piece. With it, no plugin code can run until the user opts
in by installing one — the default empty socket keeps every byte of
the existing telemetry wire byte-identical. That is the contract the
existing four sync test files were already pinning; this commit
re-pins it explicitly with a new test (tests/plugin-socket.test.ts).
- The inspector () makes rejections
visible. Without it, a misconfigured plugin would just vanish
silently — exactly the foot-gun the socket exists to prevent.
What ships:
- src/plugins/loader.ts: enumerates ~/.config/codeburn/plugins/<name>/,
reads + parses each codeburn-plugin.json, applies name/dir match and
cliCompat check, hands off to verifyPlugin (the 9b seam). Rejected
plugins contribute nothing to the wire.
- src/plugins/manifest.ts: parses + validates the manifest shape
(name, version, cliCompat, capabilities.{commands, syncAttributes,
payloadSections, spanKinds}). Returns {ok, reason} on any failure.
- src/plugins/cli.ts: . Read-only;
never mutates the plugins directory.
- src/sync/otlp.ts: filterPluginAttributes strips any key not in the
declared set, called from buildOtlpPayload when pluginAttributes are
supplied. With no plugin installed, the call path is a no-op.
- src/sync/push.ts, src/sync/cli.ts: thread pluginAttributeKeys
through sendBatches -> buildOtlpPayload. The wire is unchanged when
no plugin declares anything.
- src/usage-aggregator.ts, src/menubar-json.ts: add the
MenubarPayload.plugins field and populate it from pluginPayloadSections
(empty when no plugin installed).
- src/main.ts: register the plugin subcommand.
- tests/plugin-socket.test.ts: 12 tests covering the wire guard, the
byte-identical default, the loader rejection paths, and the CLI.
- scripts/smoke-plugin-socket.mjs: end-to-end smoke test against the
built CLI (13 assertions).
Byte-identical guarantee:
- With no plugin installed: loadPlugins() returns [], pluginPayloadSections
returns {}, filterPluginAttributes(empty) returns []. The OTLP payload
and the menubar payload are bit-for-bit the same as before this commit.
- The dry-run output gains one new line listing the active plugin names
(empty string when no plugin). User-facing only; not on the wire.
What's intentionally NOT here:
- Plugin command invocation: lands when at
least one community plugin publishes a command (deferred).
- : shipping this with no signed-plugin
ecosystem in place would invite supply-chain risk. Deferred.
- Release-key signing (the verifyPlugin return path). Deferred to 9b.
Refs: teams issue #3, CB-3 sync boundary spec section 9a.
* fix(omp): surface nested agent activity
* fix(omp): retain reported agent costs
* refactor(omp): remove unused message field
* fix(status): refresh OMP agent payloads
* Fix Grok 4.6 prompt tier pricing
* fix(omp): retain timestamps and reprice zero costs
* Test aliased Grok tier overrides
* refactor(omp): extract tier/model helpers, pass structured subagent fields
- models.ts: pull the Grok 4.6 high-tier swap into tieredCostsFor() so
the next tiered model extends one branch instead of copy-pasting the
inline condition in calculateCost.
- pi.ts: extract the message-model resolution chain into
resolveMessageModel(); document the zero-reported-cost heuristic
(OMP writes cost.total = 0 for xai-oauth, so zero acts as absent).
- usage-aggregator.ts: stop formatting a display string into the OMP
subagent row's name field; the payload carries agentName plus the
structured fields (model, startedAt, token counts) and the menubar
formats its own label. Removes the now-unused compactTokenCount.
---------
Co-authored-by: AgentSeal <resham0209@gmail.com>
* feat(cli): cache the menubar-json base payload on the optimize path (#1135)
The status snapshot keyed on a corpus fingerprint (#999) currently
excludes the default optimize path: persisting scanAndDetect's
findings alongside the base payload would pin a moment-in-time render
of the mutable, non-fingerprinted inputs the corpus fingerprint cannot
observe (settings.json, CLAUDE.md, defined skills/agents/commands,
MCP config) under a key with no way to invalidate them. That is the
exact defect commit 113ebb1c excluded the optimize path to avoid.
This change picks option (b) from the #1135 plan: the snapshot now
covers BOTH optimize=true and optimize=false (the queryKey already
carries optimize as a boolean, so the two are naturally partitioned
without a separate cache namespace). On a hit, the base payload is
served from disk and only the optimize block is re-derived fresh
through a new computeOptimizeBlock helper (re-running scanAndDetect
over a fresh parseAllSessions of the unchanged corpus). On a save,
the optimize block is stripped from the persisted record and
replaced with an empty placeholder, so a mutation in the
non-fingerprinted inputs between two requests at the same fingerprint
is reflected in the second response even though the rest of the
payload is byte-identical to the first. The save gate from #999 is
preserved verbatim (payload.stale !== true, payload.hydration ===
undefined, isSessionHydrationComplete()), the resident codeburn serve
in-memory output memo already keys on the full argv so the two
optimize values land in different memo entries, and the desktop
app's buildOverviewArgs has always sent the menubar-json query
without --no-optimize, so the new hit rate lights up with zero app
changes. (#1135)
* fix(cli): revert optimize-path snapshot reuse, re-validate snapshots on load (#1135)
Post-build review fix round on the optimize-path snapshot change:
1. Snapshot reuse is no longer attempted for one-shot optimize polls.
One-shot codeburn status invocations behave exactly as on main:
fresh compute, fresh generated timestamp. The pre-existing
--no-optimize one-shot save-gate behavior is unchanged.
2. The corpus-change test now holds --provider constant across both
calls, so the only delta is the appended session line and the test
proves a corpus change is picked up, not a queryKey change.
3. loadStatusSnapshot now mirrors the save gate: a persisted record
whose payload carries stale === true or a hydration block is treated
as a miss and recomputed. Belt-and-braces only; the save gate that
prevents such records is unchanged.
4. The optimize block cannot be re-derived on a snapshot hit without
the parsed corpus. scanAndDetect consumes the full ProjectSummary
trees plus a transcript scan plus ~/.claude state, so re-deriving it
pays the full fresh parse the snapshot exists to avoid on exactly
the cold processes the disk snapshot serves, and the resident serve
child gains nothing because its in-memory output memo already
dedupes a repeated argv. Deriving from warm caches is therefore not
honestly achievable, so this takes the plain exit: optimize-carrying
requests are excluded from reuse (useSnapshot = !queryScope.optimize
everywhere, the main semantics) and the computeOptimizeBlock helper,
the save-time optimize stripping, and the optimize-path snapshot
tests are reverted. Honesty over cleverness.
The CHANGELOG entry this branch added now describes the final
semantics. Full suite green, tsc clean.
* fix(tui): show quit feedback during the cold-start fill and exit on the second q (#1143)
Follow-up to #1142. With the input fix in place, q pressed during the
post-paint background index landed instantly but the exit path drained the
fill first - a deterministic ~16.5s silent wait on a 21k-file corpus.
The first q while the fill is active now arms a confirmation and renders
'Finishing background index so the next launch starts warm - press q or
Ctrl+C again to quit now' in the footer area, styled to match the
StatusBar (DIM border, ORANGE accent on the action key). The second q
takes the abrupt path, which #1109 already proved kill-safe (nothing
marked seen without being parsed, resume converges). Ctrl+C is unchanged:
it always exits through the abrupt path, whether or not the confirmation
is armed. q with no fill active exits immediately as before, with no
status-line flicker. The confirmation auto-clears the moment the fill
lands so a stale flag can never trap a later q.
The fill's indexing signal already flows to the dashboard (the #1109
banner reuses it), so no new global state was introduced. Pinning tests
added to tests/dashboard-exit.test.ts.
BREAKING NOTE FOR 0.9.22: ships in 0.9.22, not Unreleased.
* test: flush the unmount frame before the no-flicker assertion