Proj/brv setting (#673)
* feat: [ENG-2789] add user-configurable settings store and validator
Introduce FileSettingsStore + SettingsValidator backed by
<BRV_DATA_DIR>/settings.json with atomic temp+rename writes. Schema is
version-tagged and seeded with three initial keys (agentPool.maxSize,
agentPool.maxConcurrentTasksPerProject, taskHistory.maxEntries) whose
defaults reference the existing constants module so a constant change
flows through automatically. Validator centralises type and range
rules so future coupling rules (M2/M3) plug in without touching the
store. Unknown keys and out-of-range values surface as typed errors
that callers map at the transport boundary in M1 T3.
* feat: [ENG-2790] read settings.json at daemon startup
The daemon now reads the settings file once at boot and feeds the
resolved values into AgentPool (maxSize, maxConcurrentTasks) and the
per-project task-history cache (maxEntries). A new bootstrapSettings()
helper drives the wiring as a pure function so the failure paths
(missing file, corrupt JSON, partial validation failures) are
unit-tested without spinning up the daemon. FileSettingsStore gains a
readStartupSnapshot() method (and a tagged readRawValuesOrError() under
the hood) that surfaces parseError plus the list of rejected entries
so the bootstrap can log one warning per problem before falling back
to the registered defaults.
task-history-store-cache adopts a configureTaskHistoryStoreCache()
setter the daemon calls once before the first getStore() lookup so the
maxEntries override is observed by every per-project store the cache
creates afterwards. Existing stores keep their configured retention.
* feat: [ENG-2791] add SettingsEvents transport + SettingsHandler
Surfaces (CLI, TUI, WebUI) gain a transport contract for reading and
writing settings without crossing the server import boundary. The
SettingsEvents module declares the four wire events (list, get, set,
reset) and a stable SettingsItemDTO that bundles current/default plus
descriptor metadata (description, range, type, restartRequired) so
clients can render a complete settings page from a single LIST round
trip.
SettingsHandler delegates persistence and validation to the existing
FileSettingsStore and SettingsValidator and maps every validator
exception to a typed structured response carrying key, value, and a
human-readable message. The daemon now shares a single FileSettingsStore
instance between the startup bootstrap and the transport surface so
clients observe the same on-disk state the daemon resolved at boot.
* feat: [ENG-2792] add brv settings oclif command group
Adds the user-facing CLI surface for the configurable settings feature:
brv settings (list), brv settings list (alias), brv settings get,
brv settings set, brv settings reset. Each command speaks to the
daemon's SettingsEvents over the transport client, so the CLI never
reads or writes settings.json directly.
list renders a fixed-width table with KEY, CURRENT, DEFAULT, and
RESTART columns. set parses its argument as a number when finite so
the daemon validator surfaces range-style errors instead of always
reporting "expected integer"; non-numeric input still flows through
as a string. Validation failures bubble up as exit-code-1 with the
daemon's structured message, and every command supports --format json
with a stable shape suitable for scripting and CI use.
* docs: [ENG-2793] add Settings section to README
Adds a self-contained Settings section under the Commands collapsible
in README so a new user can discover the brv settings command group
without reading source. The section names the three M1 keys with their
defaults and what each one controls, points at the platform-specific
locations of settings.json plus the BRV_DATA_DIR override, and shows a
copy-paste example session (list, set, get) whose output matches the
CLI's actual format.
Restart-required semantics are stated once, explicitly, with the
rationale that every agent observes the same configuration for the
lifetime of the daemon. Validation-error behaviour (exit code 1 plus
the daemon's message) and the --format json flag are mentioned at the
end so scripting and CI consumers know how to detect failures.
* feat: [ENG-2794] plumb agentic loop timeout through AgentLLMService config
The agent's service-initializer now constructs AgentLLMService with a
timeout field sourced from an agent-side settings snapshot. The snapshot
is loaded once during agent bootstrap (alongside the existing
GET_PROJECT_CONFIG / GET_AUTH / GET_PROVIDER_CONFIG fan-out) and cached
for the lifetime of the agent process, so per-session services and the
hot-swap path observe a stable configuration.
The new settings/agent-settings-snapshot module exposes a synchronous
getAgentSettingValue accessor that CipherAgent reads when building
sessionLLMConfig in both start() and refreshProviderConfig(). When the
key is absent (M2 T2 has not registered it yet, or the user has not
overridden the default), the snapshot returns undefined and
AgentLLMService falls back to its existing 10-minute internal default.
M3 will reuse the same loader for llm.requestTimeoutMs.
* feat: [ENG-2795] register llm.iterationBudgetMs setting key
Adds the agentic-loop wall-clock budget to the settings registry with
default 600000 ms (10 minutes, matches the existing hardcoded
fallback) and a 60_000 to 7_200_000 ms range. The default lives in a
new AGENT_LLM_ITERATION_BUDGET_MS constant in server/constants.ts so a
single source of truth feeds both the registry and the AgentLLMService
internal fallback; the previously hardcoded 600_000 literal in
agent-llm-service.ts is replaced with the constant.
brv settings list, get, set, reset all pick up the key automatically
because the daemon-side handler walks SETTINGS_REGISTRY. The agent-side
snapshot loader from ENG-2794 now sees a real value for the key when a
user has overridden it, so the agentic loop honours the new budget
after a restart. The README's Settings table gains a row plus a
paragraph explaining when to raise the value (slow local LLMs) and
when to lower it (cloud providers, faster failure detection).
* feat: [ENG-2796] add AbortSignal plumbing for direct LLM HTTP calls
A new ai-sdk-abort-helper module exposes withRequestTimeout for
one-shot calls and createAbortContext for the streaming iterator
path. Both keyed on a configurable timeout, both translate a
timer-driven abort into a typed LlmRequestTimeoutError. The retry
layer already classifies that error as retryable via the existing
"timeout" substring in DEFAULT_RETRY_POLICY.retryableErrors, so a
hung Ollama or LM Studio connection now aborts cleanly and retries
instead of consuming the full agentic loop budget.
AiSdkContentGenerator gains a requestTimeoutMs config field that
flows into generateText (via withRequestTimeout) and streamText (via
createAbortContext). When the field is undefined no signal is built
and behaviour matches today. M3 T2 wires the value from the
llm.requestTimeoutMs setting; until then the field is plumbed but
unused in production.
* feat: [ENG-2797] register llm.requestTimeoutMs + coupling rule
Registers the per-request HTTP timeout setting (default 120000 ms, 2
minutes; range 10000 to 7200000 ms) with the agentic-loop budget as
its coupling partner: SettingsValidator.validateCoupling enforces
llm.requestTimeoutMs <= llm.iterationBudgetMs, and FileSettingsStore
gates writes through both per-key range checks and the coupling rule
so violations like raising requestTimeoutMs above iterationBudgetMs
are rejected with a message naming both keys and their values.
partition() applies the same rule to startup reads: when the on-disk
file violates the coupling rule, both coupled keys are demoted to the
invalid list and the bootstrap warning loop logs one entry per key,
matching the project AC of falling back to defaults for both. The
agent-side snapshot now flows requestTimeoutMs through
SessionLLMConfig and SessionManager into the provider registry, which
hands it to each direct AI SDK provider's AiSdkContentGenerator; the
ByteRover provider keeps its existing transport-layer timeout. The
README's Settings section gains the new key, the coupling rule, and a
three-row preset table covering cloud, fast-GPU local, and CPU local
profiles.
* feat: [ENG-2798] add /settings TUI page
Adds an interactive /settings slash command that lets TUI users view
and edit every registered setting without leaving the REPL. The page
fetches the full list via SettingsEvents.LIST, renders KEY / CURRENT /
DEFAULT / RESTART columns, and lets the user move the cursor with
arrow keys, press Enter to edit a value inline, Esc to abort an edit
or close the page, and R to reset the selected key to its default.
A page-level state machine keeps the React tree consistent across the
browse, edit, and saving phases. validateSettingInput rejects
non-integer or out-of-range entries locally before the round-trip,
and any structured error returned by SettingsHandler renders inline
on the affected row. After any successful save or reset the page
shows the `Run brv restart to apply.` banner so users know the change
is persisted but not yet applied. format-settings.ts plus its unit
tests cover the pure formatter and input validator; the Ink component
itself follows the project's no-render-test convention.
* feat: [ENG-2800] add daemon-side task heartbeat event
Introduces TaskHeartbeatManager — a per-task liveness ticker the task
router installs at task:started and tears down on the three terminal
events (task:completed, task:error, task:cancelled). The manager
debounces a setTimeout keyed on a 10-second interval so a quiet task
fires task:heartbeat exactly once per quiet window and a noisy task
that keeps forwarding LLM events never produces redundant heartbeats.
The router calls recordActivity inside routeLlmEvent so every
forwarded LlmEvent resets the timer, register inside handleTaskStarted
when the agent confirms the task is running, and recordTermination at
each of the three terminal lifecycle paths plus the agent-disconnect
failTask path and the local-cancel branch. The daemon constructs one
shared manager in brv-server.ts and threads it through
TransportHandlers; the emit callback hands the heartbeat payload to
transport.sendTo plus broadcastToProjectRoom so the same fanout the
router already uses for other task-scoped events delivers the new
event without bespoke plumbing. The CLI watcher consuming this event
lands in ENG-2802.
* feat: [ENG-2801] soft-drop --timeout flag on curate/query/dream
The legacy --timeout flag drove a CLI wall-clock timer that fired
before the daemon's real iteration budget on slow LLM calls. Removing
the flag would break existing scripts and CI jobs, so this change
keeps it accepted but neutralises it: the flag definition stays, the
help text is now the canonical "(deprecated, no effect, kept for
compatibility)" string, and passing a non-default value prints a
single deprecation warning pointing at llm.iterationBudgetMs as the
replacement.
The value is no longer threaded into waitForTaskCompletion — every
call site now passes DEFAULT_TIMEOUT_SECONDS * 1000 unconditionally so
the wait-for-task wall-clock stops responding to user input. ENG-2802
removes the timer outright once the heartbeat watcher lands. A shared
timeout-deprecation helper centralises the message and the
user-passed-vs-default detection so the three commands stay in sync.
* feat: [ENG-2802] replace CLI wall-clock with heartbeat watcher
waitForTaskCompletion no longer enforces a wall-clock timeout. The
setTimeout block that emitted "Task timed out after Xs" is gone; the
unused timeoutMs option is dropped from WaitForTaskOptions and from
curate / query / dream call sites. The disconnect-grace path stays
intact and keeps mapping socket death to a retryable
AGENT_DISCONNECTED error.
In its place, a setInterval-driven stale watcher tracks
lastActivityAt: every LLM event, the new TaskEvents.HEARTBEAT
subscription, and the existing task lifecycle events refresh the
timestamp. When 30s elapse without any activity (3x the daemon's
10s heartbeat cadence so a single missed beat is not a false
positive), the CLI rejects with "Daemon is unresponsive on this
task". Slow but progressing tasks ride a continuous stream of
heartbeats and never trip the watcher; truly stuck daemons surface
quickly without the misleading wall-clock message.
* chore: review fixes for proj/brv-setting
M-1 (logic flaw, FileSettingsStore.reset): The previous implementation
ran partition() on the remaining raw values after deleting the target
key, which silently demoted (and then dropped) any OTHER invalid
pre-existing entries — a single `brv settings reset` could wipe the
whole settings file when the user only asked to reset one key. reset
now writes back the raw values verbatim minus the deleted key, so
unrelated invalid entries survive until the startup loader surfaces
them as warnings. SettingsFile.values is retyped as
Record<string, unknown> to reflect that the persisted shape can
legitimately retain such entries.
M-2 (TUI UX gap): SettingsPage gated both useInput handlers on browse
/ edit, leaving the user trapped if a SET write hung. A third handler
active during 'saving' now exits on Esc; the in-flight mutation
resolves in the background.
M-3 (Outside-In, magic strings): The five setting key names were
duplicated as inline string literals across SettingsValidator,
settings-bootstrap, cipher-agent, and the registry itself. A single
SETTINGS_KEYS const in core/domain/entities/settings.ts now feeds all
call sites — a rename is now a compile error everywhere instead of a
silent miss in getAgentSettingValue.
L-1 (CLI heartbeat correctness): The watcher's LLM event handlers
checked `if (!data.taskId) return` (presence only) instead of equality
against the awaited taskId. In multi-task scenarios where the CLI
received broadcast events for other tasks, markActive() falsely
refreshed our stale clock. Handlers now mirror the HEARTBEAT filter
(`data.taskId !== taskId` → return). Added a regression test that
streams events for a different taskId and asserts the watcher still
surfaces the stuck-daemon error.
* fix: [ENG-2801, ENG-2802] task:cancelled handler + deprecation wording
ENG-2802 (M6 T3): CLI heartbeat watcher missed the task:cancelled
terminal event. A cancel arriving with no following activity would
trip the stale-check and falsely reject with "Daemon is unresponsive
on this task". Add a CANCELLED subscriber that disposes cleanly and
covers the 4th AC path with a unit test.
ENG-2801 (M6 T2): the deprecation message embedded the
llm.iterationBudgetMs setting key. M6 T2 AC explicitly requires the
wording to omit any specific setting key so M6 ships independently
of M1/M2/M3 and survives setting renames. Drop the suffix and lock
the new wording in both the dedicated test and the per-command
deprecation tests.
* feat: [ENG-2816] add shared duration and count formatter + parser
Pure-utility module at src/shared/utils/format-duration.ts exporting
formatDuration(ms), formatCount(n), and parseDuration(input). Foundation
for M7 T3 (oclif renderers) and M7 T4 (TUI page redesign): both surfaces
will import these helpers so values render identically and human input
syntax stays consistent.
parseDuration accepts s / m / h / ms parts (case-insensitive, multi-part
with optional whitespace) plus bare ms integers for back-compat with
existing scripts. Fractions, unknown units, empty, and malformed inputs
return a discriminated DurationParseError carrying a user-facing hint.
* feat: [ENG-2817] add category/unit/scope DTO fields + tighten registry maxes
Descriptor extension (src/server/core/domain/entities/settings.ts):
- New optional category: 'concurrency' | 'llm' | 'task-history' on every
descriptor. Drives group headers in CLI and TUI renderers (M7 T3 / T4)
and the future WebUI page (ENG-2799) without parsing key prefixes.
- New optional unit: 'ms' | 'count'. The two llm.*Ms descriptors set
unit: 'ms'; surfaces dispatch on this for duration vs count formatter.
- Tightened maxes (LLM keys: 7_200_000 -> 3_600_000 ms; taskHistory: 100_000
-> 10_000). Existing partition() handles out-of-range on-disk values at
startup with a logged warning, so pre-existing settings.json files fall
back to defaults cleanly without a migration script.
- Description strings condensed to <= 80 chars to keep WebUI tooltip
budgets tight; no information loss.
DTO additive shape (src/shared/transport/events/settings-events.ts):
- SettingsItemDTO gains optional category, unit, and scope fields.
Existing fields are unchanged. JSON consumers parsing the prior shape
continue to work. scope is reserved for the future project-store
ticket and is always omitted by the daemon in v1.
Handler propagation (src/server/infra/transport/handlers/settings-handler.ts):
- LIST and GET now forward descriptor.category and descriptor.unit onto
every emitted item. Extracted via a shared descriptorToDTO helper to
keep LIST and GET in sync.
* feat: [ENG-2818] grouped settings list + parseDuration dispatch on `brv settings set`
`brv settings list` (text mode) now groups rows by descriptor category
(CONCURRENCY -> LLM -> TASK HISTORY), renders ms-unit values in human
form (10m, 2m), thousands-separates count values, drops the redundant
RESTART? column entirely, and prints an inline coupling hint on
llm.requestTimeoutMs (`10s-1h, max loop budget`). Scope header line
`Settings - scope: global` reserves space for the future project-store
ticket without changing layout today.
`brv settings get <key>` (text mode) shifts from a single
"<current> (default: <default>)" line to a labelled multi-line block
showing key / current / default / range / scope, with current and
default formatted per the descriptor's unit field.
`brv settings set <key> <value>` now fetches the descriptor first
(GET), then dispatches:
- unit='ms' keys route through parseDuration (T1) — accepting `30m`,
`1h 30m`, `1H 30M`, bare ms integer for back-compat;
- unit='count' keys parse as integer after stripping commas;
- cross-unit input is rejected locally with a clear message
(`agentPool.maxSize expects an integer count, got duration '30m'`).
On success, stdout echoes the value in human form
(`Setting saved: <key> = <human>. Run `brv restart` to apply.`).
`brv settings reset <key>` follows the same fetch-first pattern so the
success message can name the default in human form
(`Setting reset: <key> back to default (10m). Run `brv restart` to apply.`).
--format json output is purely additive: existing fields keep their
names and types; `category` / `unit` / `scope` appear only when the
DTO carries them (which is "always" for category/unit and "never" in
v1 for scope).
Also tightened the parseDuration unknown-unit hint to match the AC
phrasing exactly: "invalid duration: try 30m, 1h, 1h 30m, or a raw
ms integer.".
* feat: [ENG-2819] redesign TUI /settings page: groups, in-place edit, no colours
Browse mode now renders rows in category groups (CONCURRENCY -> LLM ->
TASK HISTORY) with section headers, drops the dead RESTART? column, and
shows the human-formatted value plus default-in-parens plus range on
every row. ASCII `>` is the only selection signal; no theme colours
applied to row content, restart banner, or status messages.
Edit mode transforms the focused row in place: `<current> -> [<buffer>_]`.
The verbose inline `(min X, max Y; Enter to save, Esc to cancel)` hint
is gone — replaced by a context-aware bottom hint line that swaps text
across browse / edit / edit-error / saving modes. Buffer pre-fills with
the displayed human form (`10m` for ms keys, `1000` for counts) so the
user edits what they see.
format-settings.ts now exports four shared helpers the page and its
tests both use:
- buildSettingsRows: groups by category, formats per unit (ms / count),
inlines the coupling hint on llm.requestTimeoutMs, falls back to
category=other / unit=count when the DTO omits them.
- groupRowsByCategory: returns [header, rows] groups in fixed order.
- bottomHintFor(mode, focusedKey): the four mode strings.
- parseRowInput(row, raw): mirrors the oclif `set` dispatch so duration
vs count parsing stays consistent across surfaces; returns either a
numeric value + display string, or a user-facing error message.
The legacy `validateSettingInput` helper stays for any caller that has
not migrated to `parseRowInput` yet (no in-tree callers do today, but
keeping the export avoids a breaking surface change in this ticket).
* fix: [ENG-2819] colour selected row and inline error on TUI /settings
T4 over-stripped theme colour usage. Colour should signal functional UI
state (where the cursor is, what failed), just not data state (modified
vs default — that one is already conveyed by the default-in-parens
column).
Restore three colour applications on the page:
- Selected row content rendered with colors.primary so the user can see
which row has focus at a glance. Non-selected rows stay plain text.
- Inline validation error rendered with colors.errorText so the message
reads as a warning, not as another line of body copy.
- Restart banner rendered with colors.warning since it is a one-shot
notification ("Settings changed. Run `brv restart` to apply.").
No colour is applied to row metadata columns (default, range) or to
distinguish modified rows from defaults — that decision from M7 T4
holds. ASCII `>` cursor still renders alongside the colour so screen
captures and pipe-redirected output keep the selection signal.
* feat: [ENG-2799] add webui settings panel to /configuration
* feat: [ENG-2799] redesign connectors tab and merge LLM into General
* feat: [ENG-2799] address PR review: install tooltip + toast message helper
* feat: [ENG-2799] add divider between configuration sidebar and content
* feat: [ENG-2799] update connector icons with brand artwork
* feat: [ENG-2799] address PR review: qoder fill, cursor viewBox, layout padding
* fix: cold-start false-reject and stream idle-deadline for slow LLMs
Two regressions surfaced by the PR review on #673:
- CLI task watcher subscribed only to heartbeat/LLM events, none of
which fire during the agent fork + auth/provider init pipeline.
Cold starts that exceeded 30s (Windows under AV, slow disks) triggered
a false "Daemon is unresponsive" rejection. Watcher now also listens
for task:created (synchronous at T+0), task:ack, and task:started,
all filtered by taskId so foreign-task broadcasts do not pollute.
- The streaming AbortSignal was a total deadline measured from
construction; a long-form completion that streamed chunks steadily for
longer than llm.requestTimeoutMs aborted as LlmRequestTimeoutError
even while data was flowing. Added AbortContext.recordActivity() that
resets the timer; the streaming generator loop calls it on every chunk
so the deadline becomes idle-deadline for streams. Non-streaming
generateContent uses withRequestTimeout unchanged, keeping the
total-deadline semantic correct for one-shot Promises.
Note: llm.requestTimeoutMs description in SETTINGS_REGISTRY still reads
"Max wait time per LLM response", which is now ambiguous for streams.
Description update is intentionally NOT in this commit -- needs design
call (update description vs revert to total vs split into two settings).
Tests: 1 cold-start regression test on task-client-heartbeat (the
existing 30s-no-event reject test stays as-is to lock the
no-activity stall behavior); 4 idle-deadline contract tests on
createAbortContext (idle reset, stall still detected, post-fire no-op,
undefined-timeoutMs no-op).
* test: add edge-case coverage for heartbeat watcher and abort idle-deadline
Adversarial gap analysis on top of 9da640a uncovered four scenarios the
existing tests did not lock down. All new tests fail without the prior
fixes and pass against the current implementation.
task-client heartbeat watcher (+3 cases):
- foreign-task task:created / task:ack / task:started must NOT bump our
watcher (regression guard for the new lifecycle subscriptions: without
the taskId filter, a noisy peer task in the same project room would
keep our watcher alive forever and mask real stalls)
- single task:created bump followed by 30s of silence still triggers
the stale rejection (proves the bump shifts the deadline but does not
permanently disable the watcher)
- two concurrent watchers for different taskIds do not bump each other
on lifecycle events — per-watcher taskId filter isolates them
ai-sdk-abort-helper (+1 case):
- many rapid recordActivity() calls do not leak setTimeout handles;
the stream loop calls recordActivity on EVERY chunk, so a slow model
emitting hundreds of chunks per second must not accumulate timers.
Asserts clock.countTimers() stays at 1 across 500 rapid bumps and
drops to 0 after cleanup()
---------
Co-authored-by: ncnthien <nhatthien185@gmail.com> B
bao-byterover committed
f14047caeb00cabc914905279885be97a0cad00d
Parent: bd7c365
Committed by GitHub <noreply@github.com>
on 5/20/2026, 2:40:14 AM