Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@
"commands": "./dist/oclif/commands",
"hooks": {
"init": [
"./dist/oclif/hooks/init/block-autoupdate-when-off",
"./dist/oclif/hooks/init/block-command-update-npm",
"./dist/oclif/hooks/init/welcome",
"./dist/oclif/hooks/init/update-notifier",
Expand Down
4 changes: 3 additions & 1 deletion src/agent/infra/settings/agent-settings-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ export async function loadAgentSettingsSnapshot(client: ITransportClient): Promi
const response = await client.requestWithAck<SettingsListResponse>(SettingsEvents.LIST)
const values: Record<string, number> = {}
for (const item of response.items) {
values[item.key] = item.current
// Boolean descriptors round-trip the same wire shape but no agent
// consumer reads them today; ship-when-needed in a later ticket.
if (typeof item.current === 'number') values[item.key] = item.current
}

snapshot = values
Expand Down
22 changes: 13 additions & 9 deletions src/oclif/commands/settings/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,14 @@ export default class SettingsGet extends Command {
}

private printTextBlock(item: SettingsItemDTO): void {
const current = renderValue(item, item.current)
const defaultStr = renderValue(item, item.default)
const min = renderValue(item, item.min)
const max = renderValue(item, item.max)

this.log(item.key)
this.log(` current: ${current}`)
this.log(` default: ${defaultStr}`)
this.log(` range: ${min}-${max}`)
this.log(` current: ${renderValue(item, item.current)}`)
this.log(` default: ${renderValue(item, item.default)}`)
if (item.type === 'integer' && item.min !== undefined && item.max !== undefined) {
const range = `${renderInteger(item, item.min)}-${renderInteger(item, item.max)}`
this.log(` range: ${range}`)
}

this.log(` scope: ${item.scope ?? 'global'}`)
}

Expand All @@ -104,7 +103,12 @@ export default class SettingsGet extends Command {
}
}

function renderValue(item: SettingsItemDTO, value: number): string {
function renderValue(item: SettingsItemDTO, value: boolean | number): string {
if (typeof value === 'boolean') return value ? 'true' : 'false'
return renderInteger(item, value)
}

function renderInteger(item: SettingsItemDTO, value: number): string {
if (item.unit === 'ms') return formatDuration(value)
return formatCount(value)
}
17 changes: 12 additions & 5 deletions src/oclif/commands/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ import {formatCount, formatDuration} from '../../../shared/utils/format-duration
import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js'
import {writeJsonResponse} from '../../lib/json-response.js'

type CategoryName = 'concurrency' | 'llm' | 'task-history'
type CategoryName = 'concurrency' | 'llm' | 'task-history' | 'updates'

const CATEGORY_ORDER: readonly CategoryName[] = ['concurrency', 'llm', 'task-history']
const CATEGORY_ORDER: readonly CategoryName[] = ['concurrency', 'llm', 'task-history', 'updates']

const CATEGORY_HEADERS: Readonly<Record<CategoryName, string>> = {
concurrency: 'CONCURRENCY',
llm: 'LLM',
'task-history': 'TASK HISTORY',
updates: 'UPDATES',
}

const OTHER_HEADER = 'OTHER'
Expand Down Expand Up @@ -113,14 +114,20 @@ function formatRow(item: SettingsItemDTO): string {
return ` ${pad(item.key, 40)} ${pad(current, 7)} (default ${defaultStr})${''.padEnd(Math.max(0, 8 - defaultStr.length))} ${range}`
}

function renderValue(item: SettingsItemDTO, value: number): string {
function renderValue(item: SettingsItemDTO, value: boolean | number): string {
if (typeof value === 'boolean') return value ? 'true' : 'false'
return renderInteger(item, value)
}

function renderInteger(item: SettingsItemDTO, value: number): string {
if (item.unit === 'ms') return formatDuration(value)
return formatCount(value)
}

function renderRange(item: SettingsItemDTO): string {
const min = renderValue(item, item.min)
const max = renderValue(item, item.max)
if (item.type !== 'integer' || item.min === undefined || item.max === undefined) return ''
const min = renderInteger(item, item.min)
const max = renderInteger(item, item.max)
const base = `${min}-${max}`
if (item.key === 'llm.requestTimeoutMs') return `${base}, max loop budget`
return base
Expand Down
9 changes: 4 additions & 5 deletions src/oclif/commands/settings/reset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,8 @@ export default class SettingsReset extends Command {
success: true,
})
} else {
this.log(
`Setting reset: ${args.key} back to default (${renderValue(descriptor, descriptor.default)}). ` +
'Run `brv restart` to apply.',
)
const base = `Setting reset: ${args.key} back to default (${renderValue(descriptor, descriptor.default)}).`
this.log(descriptor.restartRequired ? `${base} Run \`brv restart\` to apply.` : base)
}

return
Expand All @@ -99,7 +97,8 @@ export default class SettingsReset extends Command {
}
}

function renderValue(item: SettingsItemDTO, value: number): string {
function renderValue(item: SettingsItemDTO, value: boolean | number): string {
if (typeof value === 'boolean') return value ? 'true' : 'false'
if (item.unit === 'ms') return formatDuration(value)
return formatCount(value)
}
40 changes: 36 additions & 4 deletions src/oclif/commands/settings/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ const DURATION_RE = /\d+\s*(?:ms|s|m|h)/i
export default class SettingsSet extends Command {
public static args = {
key: Args.string({description: 'Settings key to write', required: true}),
value: Args.string({description: 'New value (integer for count keys, duration like 30m / 1h 30m / 1800000 for ms keys)', required: true}),
value: Args.string({
description:
'New value (integer for count keys, duration like 30m / 1h 30m / 1800000 for ms keys, boolean true/false/on/off/1/0/yes/no for boolean keys)',
required: true,
}),
}
public static description =
'Update one settings value. Changes apply after `brv restart`.'
Expand Down Expand Up @@ -90,7 +94,8 @@ export default class SettingsSet extends Command {
success: true,
})
} else {
this.log(`Setting saved: ${args.key} = ${parsed.display}. Run \`brv restart\` to apply.`)
const base = `Setting saved: ${args.key} = ${parsed.display}.`
this.log(response.restartRequired ? `${base} Run \`brv restart\` to apply.` : base)
}

return
Expand All @@ -114,7 +119,7 @@ export default class SettingsSet extends Command {

protected async writeSetting(
key: string,
value: unknown,
value: boolean | number,
options?: DaemonClientOptions,
): Promise<SettingsSetResponse> {
return withDaemonRetry<SettingsSetResponse>(
Expand All @@ -126,14 +131,41 @@ export default class SettingsSet extends Command {
}

type ParseResult =
| {readonly display: string; readonly kind: 'ok'; readonly value: number}
| {readonly display: string; readonly kind: 'ok'; readonly value: boolean | number}
| {readonly kind: 'error'; readonly message: string}

const BOOLEAN_TOKENS = new Map<string, boolean>([
['0', false],
['1', true],
['false', false],
['no', false],
['off', false],
['on', true],
['true', true],
['yes', true],
])

const BOOLEAN_TOKENS_HINT = 'true, false, on, off, 1, 0, yes, no'

function parseValue(descriptor: SettingsItemDTO, raw: string): ParseResult {
if (descriptor.type === 'boolean') return parseAsBoolean(descriptor, raw)
if (descriptor.unit === 'ms') return parseAsDuration(descriptor, raw)
return parseAsCount(descriptor, raw)
}

function parseAsBoolean(descriptor: SettingsItemDTO, raw: string): ParseResult {
const lowered = raw.trim().toLowerCase()
const value = BOOLEAN_TOKENS.get(lowered)
if (value === undefined) {
return {
kind: 'error',
message: `${descriptor.key} expected boolean (${BOOLEAN_TOKENS_HINT}), got '${raw}'.`,
}
}

return {display: String(value), kind: 'ok', value}
}

function parseAsDuration(descriptor: SettingsItemDTO, raw: string): ParseResult {
const parsed = parseDuration(raw)
if (typeof parsed === 'number') {
Expand Down
37 changes: 37 additions & 0 deletions src/oclif/hooks/init/block-autoupdate-when-off.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type {Hook} from '@oclif/core'

import {checkForUpdatesSetting} from '../../lib/check-for-updates-setting.js'

export type BlockAutoupdateWhenOffDeps = {
argv: readonly string[]
commandId: string | undefined
exitFn: (code: number) => never
}

/**
* Short-circuits `brv update --autoupdate` (the background invocation
* `@oclif/plugin-update` spawns on a debounce) when the user has set
* `update.checkForUpdates` to `false`. Exits silently with code 0 so
* plugin-update sees a normal-looking termination rather than an error
* it might retry or log.
*
* The user-initiated `brv update` (no `--autoupdate` flag) is **not**
* blocked — that remains the user's manual escape hatch.
*/
export function handleBlockAutoupdateWhenOff(deps: BlockAutoupdateWhenOffDeps): void {
if (deps.commandId !== 'update') return
if (!deps.argv.includes('--autoupdate')) return
if (checkForUpdatesSetting()) return
deps.exitFn(0)
}
Comment on lines +21 to +26
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (non-blocking): Hook ordering — the PR description (and package.json:159-164) registers block-autoupdate-when-off before block-command-update-npm, which is critical: if reversed, the npm-install + setting-off + autoupdate path would log a noisy exit-1 instead of the intended silent exit.

There's no test guard that asserts this ordering. Given the risk you've already documented in the PR body ("If a future refactor reorders the hooks..."), would it be worth adding an integration-style test that reads package.json and asserts block-autoupdate-when-off precedes block-command-update-npm in oclif.hooks.init? Cheap to write, gives you a CI tripwire if anyone alphabetizes or reorders the array.


const hook: Hook<'init'> = function (opts): Promise<void> {
handleBlockAutoupdateWhenOff({
argv: opts.argv,
commandId: opts.id,
exitFn: process.exit,
})
return Promise.resolve()
}

export default hook
30 changes: 28 additions & 2 deletions src/oclif/hooks/init/update-notifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {confirm} from '@inquirer/prompts'
import {execSync, spawn} from 'node:child_process'
import updateNotifier from 'update-notifier'

import {checkForUpdatesSetting} from '../../lib/check-for-updates-setting.js'

/**
* Check interval for update notifications (1 hour)
*/
Expand Down Expand Up @@ -46,7 +48,29 @@ export const isNpmGlobalInstall = (execSyncFn: typeof execSync): boolean => {
}

/**
* Core update notification logic, extracted for testability
* Resolves whether the init hook should run the update check at all.
*
* Returns `false` when any of the following holds:
* - The `update.checkForUpdates` setting is explicitly `false`.
* - The command being invoked is itself `update` (anti-recursion).
* - `BRV_ENV` is `development` (dev installs handle updates manually).
*
* Returns `true` otherwise (default behaviour).
*/
export function shouldRunUpdateCheck(args: {commandId: string | undefined}): boolean {
if (args.commandId === 'update') return false
if (process.env.BRV_ENV === 'development') return false
return checkForUpdatesSetting()
}
Comment on lines +60 to +64
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Clean three-condition gate with explicit short-circuit ordering — commandId === 'update' is checked first to avoid an unnecessary filesystem read on the recursive path, then BRV_ENV === 'development', then the disk read. Good order. The test coverage in update-notifier.test.ts:212-258 covers each condition independently, and the new shouldRunUpdateCheck boundary keeps handleUpdateNotification itself pure (no env / disk side effects). Nice extraction.


/**
* Core update notification logic, extracted for testability.
*
* Interactive flow only: shows a confirm prompt, then runs `npm update -g`
* + `brv restart` if the user types `y`. There is **no** unattended /
* automatic execution path — that was deliberately rejected by the team
* (May 2026). When the user types `n` (or the prompt is unavailable),
* the hook just notifies and returns.
*/
export async function handleUpdateNotification(deps: UpdateNotifierDeps): Promise<void> {
const {confirmPrompt, execSyncFn, exitFn, isNpmGlobalInstalled, isTTY, log, notifier} = deps
Expand Down Expand Up @@ -89,7 +113,9 @@ export async function handleUpdateNotification(deps: UpdateNotifierDeps): Promis
}
}

const hook: Hook<'init'> = async function (): Promise<void> {
const hook: Hook<'init'> = async function (opts): Promise<void> {
if (!shouldRunUpdateCheck({commandId: opts.id})) return

const pkgInfo = {name: this.config.name, version: this.config.version}
const notifier = updateNotifier({pkg: pkgInfo, updateCheckInterval: UPDATE_CHECK_INTERVAL_MS})
const isNpmGlobalInstalled = isNpmGlobalInstall(execSync)
Expand Down
41 changes: 41 additions & 0 deletions src/oclif/lib/check-for-updates-setting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {existsSync, readFileSync} from 'node:fs'
import {join} from 'node:path'

import {getGlobalDataDir} from '../../server/utils/global-data-path.js'

const SETTINGS_KEY = 'update.checkForUpdates'
const SETTINGS_FILENAME = 'settings.json'

/**
* Synchronous read of the user's `update.checkForUpdates` setting.
*
* Default is `true` (update check enabled). Returns `false` only when the
* persisted settings file explicitly contains
* `{values: {"update.checkForUpdates": false}}`.
*
* Lives in `oclif/lib/` because the only consumers today are oclif init
* hooks that must run before the daemon connection is established and
* therefore cannot read the setting via the `settings:list` transport
* event. Path resolution reuses `getGlobalDataDir()` so the helper lands
* on the exact same file the daemon writes to.
*/
export function checkForUpdatesSetting(): boolean {
try {
const path = join(getGlobalDataDir(), SETTINGS_FILENAME)
if (!existsSync(path)) return true

const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (non-blocking): readFileSync is used here without a size guard. If a user (or a corrupted writer) ever produces a multi-megabyte settings.json, this synchronous read runs on every invocation of brv. The PR body notes "single read of a small file; measured well below 10 ms" — which is true for the happy path — but the test suite doesn't pin that assumption.

Strictly optional: add an fs.statSync size check and bail to default if the file is unexpectedly large (e.g. > 1 MB). The current try/catch already handles parse failures, so this is purely defense-in-depth against a pathologically slow startup. Skip if you think it's overkill.

if (!isRecord(parsed)) return true
if (!isRecord(parsed.values)) return true

const value = parsed.values[SETTINGS_KEY]
if (typeof value !== 'boolean') return true
return value
} catch {
return true
}
}
Comment on lines +22 to +37
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): The fallback values (return true on lines 25, 28, 29, 32, 35) hardcode true, duplicating the default that the registry sources from UPDATE_CHECK_FOR_UPDATES_DEFAULT in src/server/constants.ts:137.

Per CLAUDE.md: "Descriptors in server/core/domain/entities/settings.ts reference src/constants.ts so a constant change updates the setting's default automatically." This helper bypasses that wiring, so if the team ever flips the default to false (or makes it env-conditional) the constant will change but this helper will silently keep returning true — the very drift the registry pattern was designed to prevent.

Suggested change
export function checkForUpdatesSetting(): boolean {
try {
const path = join(getGlobalDataDir(), SETTINGS_FILENAME)
if (!existsSync(path)) return true
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
if (!isRecord(parsed)) return true
if (!isRecord(parsed.values)) return true
const value = parsed.values[SETTINGS_KEY]
if (typeof value !== 'boolean') return true
return value
} catch {
return true
}
}
import {UPDATE_CHECK_FOR_UPDATES_DEFAULT} from '../../server/constants.js'
export function checkForUpdatesSetting(): boolean {
try {
const path = join(getGlobalDataDir(), SETTINGS_FILENAME)
if (!existsSync(path)) return UPDATE_CHECK_FOR_UPDATES_DEFAULT
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
if (!isRecord(parsed)) return UPDATE_CHECK_FOR_UPDATES_DEFAULT
if (!isRecord(parsed.values)) return UPDATE_CHECK_FOR_UPDATES_DEFAULT
const value = parsed.values[SETTINGS_KEY]
if (typeof value !== 'boolean') return UPDATE_CHECK_FOR_UPDATES_DEFAULT
return value
} catch {
return UPDATE_CHECK_FOR_UPDATES_DEFAULT
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

Note: the suggestion above adds an import that this file doesn't currently have — apply the import line separately if you accept it.


function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
9 changes: 8 additions & 1 deletion src/server/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,14 @@ export const REVIEW_BACKUPS_DIR = 'review-backups'

// User-configurable operational settings (global, restart-required)
export const SETTINGS_FILE = 'settings.json'
export const SETTINGS_SCHEMA_VERSION = '1'
// Bumped from '1' to '2' alongside the boolean-descriptor work: v2 files
// persist `{[key]: number | boolean}`. v1 files are migrated forward on
// first read by `FileSettingsStore`; the migration is idempotent.
export const SETTINGS_SCHEMA_VERSION = '2'

// Default for the boolean `update.checkForUpdates` setting. ON by default; users
// flip to false to disable the startup update-notifier hook entirely.
export const UPDATE_CHECK_FOR_UPDATES_DEFAULT = true

// Default wall-clock budget for the agentic loop (`llm.iterationBudgetMs`).
// Slow local-LLM users override via `brv settings set llm.iterationBudgetMs <ms>`.
Expand Down
Loading
Loading