diff --git a/README.md b/README.md index 502902e..36a3c3c 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ For the multi-repository case the model clones what is not checked out locally, **Scan** -Standard or Deep. The whole repository, selected paths, your uncommitted changes, or everything since a branch or commit. Set a time limit if you want one. Two scans can run at once. +Standard or Deep. The whole repository, selected paths, your uncommitted changes, or everything since a branch or commit. For each scan you can pick the reasoning effort, a cost limit and a time limit. Two scans can run at once. **Review findings** diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 8265b94..bab88be 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -110,6 +110,10 @@ Tell it the window your provider gives that model, and when to compact: Use the limit your provider reports for the model (the error message states it), and set the compaction limit to roughly 80% of it. These are native Codex settings, so they also work in `~/.codex/config.toml`, including inside a profile. +## Settings for one scan + +The scan review lets you change the reasoning effort, cost limit, time limit and model for that scan only. In chat, ask for it: "scan this on low effort, stop at $5". Anything you leave alone comes from the files above. + ## Time limits `scan.maxTimeMinutes` sets how long a scan investigates. A Standard scan has no limit by default and treats the value as a target: the model paces itself, and writing the report can run past it. A Deep scan stops discovery at the limit; without one the SDK's own default applies (96 hours). You can also set it for one scan in the scan review. diff --git a/src/core/scan-plan.ts b/src/core/scan-plan.ts index aefec1b..6978b19 100644 --- a/src/core/scan-plan.ts +++ b/src/core/scan-plan.ts @@ -24,6 +24,10 @@ export interface ScanInputs { contextFiles?: string[]; /** Minutes of investigation. Advisory for Standard; a discovery deadline for Deep. */ maxTimeMinutes?: number; + /** For this scan only, instead of the configured model, reasoning effort and cost limit. */ + model?: string; + reasoningEffort?: string; + maxCostUsd?: number; /** Continue an interrupted Deep scan instead of starting a new one. Other inputs are ignored. */ resumeRunId?: string; } @@ -79,6 +83,12 @@ function parseInputs(value: unknown): ScanInputs { !['undefined', 'string'].includes(typeof data.repository) || !['undefined', 'string'].includes(typeof data.scanPrompt) || !['undefined', 'string'].includes(typeof data.changes) || + !['undefined', 'string'].includes(typeof data.model) || + !['undefined', 'string'].includes(typeof data.reasoningEffort) || + !( + data.maxCostUsd === undefined || + (typeof data.maxCostUsd === 'number' && data.maxCostUsd > 0) + ) || !strings(data.scopePaths) || !strings(data.contextFiles) || ![undefined, 'standard', 'deep'].includes(data.mode as string) || @@ -88,7 +98,7 @@ function parseInputs(value: unknown): ScanInputs { ) ) throw new SecurityInputError( - 'Scan inputs are repository, scopePaths, changes, scanPrompt, contextFiles, mode (standard or deep) and maxTimeMinutes.', + 'Scan inputs are repository, scopePaths, changes, scanPrompt, contextFiles, mode (standard or deep), maxTimeMinutes, model, reasoningEffort and maxCostUsd.', ); if (data.changes && (data.scopePaths as string[] | undefined)?.length) throw new SecurityInputError('Scan either paths or changes, not both.'); @@ -215,11 +225,19 @@ export async function prepareScan( const scanSettings = { ...settings.scan, ...(input.maxTimeMinutes ? { maxTimeMinutes: input.maxTimeMinutes } : {}), + ...(input.maxCostUsd ? { maxCostUsd: input.maxCostUsd } : {}), + }; + const codex = { + ...settings.codex, + ...(input.model?.trim() ? { model: input.model.trim() } : {}), + ...(input.reasoningEffort?.trim() + ? { model_reasoning_effort: input.reasoningEffort.trim() } + : {}), }; if (purpose === 'validation') delete scanSettings.maxCostUsd; const changes = input.changes?.trim() || undefined; const target = await scanTarget({ scopePaths: input.scopePaths, changes }); - return withSdk(settings.codex, async (client, sdk) => { + return withSdk(codex, async (client, sdk) => { const preflight = await client.preflight(repository, { ...scanSettings, auth: settings.auth, @@ -236,11 +254,11 @@ export async function prepareScan( scanPrompt: input.scanPrompt ?? '', contextFiles: contextFiles.map((path) => ({ path })), settings: { - codex: (await routeFrom(sdk, settings.codex, preflight)).route, + codex: (await routeFrom(sdk, codex, preflight)).route, scan: { ...scanSettings, mode: preflight.mode }, }, globalConfigFile: settings.path, - sdk: { codexOverrides: settings.codex, auth: settings.auth }, + sdk: { codexOverrides: codex, auth: settings.auth }, expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), mock, // No undefined members: the host only carries JSON values over RPC. diff --git a/src/gui/ScanComposer.tsx b/src/gui/ScanComposer.tsx index 3a11784..7ffb01b 100644 --- a/src/gui/ScanComposer.tsx +++ b/src/gui/ScanComposer.tsx @@ -1,7 +1,7 @@ -import { createSignal, Show } from 'solid-js'; +import { createSignal, For, Show } from 'solid-js'; import { asState } from '../types'; import type { ScanInputs, ScanPlan } from '../core/scan-plan'; -import { splitPaths, userError } from '../scan-display'; +import { reasoningEfforts, splitPaths, userError } from '../scan-display'; import { ScanReview } from './ScanReview'; import type { SecurityPort, SecurityStore } from './store'; @@ -41,6 +41,9 @@ export function ScanComposer(props: { initial?.contextFiles.map((file) => file.path).join('\n') ?? '', ); const [minutes, setMinutes] = createSignal(initial?.inputs.maxTimeMinutes?.toString() ?? ''); + const [effort, setEffort] = createSignal(initial?.inputs.reasoningEffort ?? ''); + const [cost, setCost] = createSignal(initial?.inputs.maxCostUsd?.toString() ?? ''); + const [model, setModel] = createSignal(initial?.inputs.model ?? ''); const [preview, setPreview] = createSignal( props.edit ? undefined : initial, ); @@ -73,6 +76,9 @@ export function ScanComposer(props: { contextFiles: splitPaths(documents()), ...(mode() !== 'default' ? { mode: mode() as 'standard' | 'deep' } : {}), ...(Number(minutes()) > 0 ? { maxTimeMinutes: Number(minutes()) } : {}), + ...(effort() ? { reasoningEffort: effort() } : {}), + ...(Number(cost()) > 0 ? { maxCostUsd: Number(cost()) } : {}), + ...(model().trim() ? { model: model().trim() } : {}), }; const next = asState(await props.port.prepare(input)); if (!next.plan) throw new Error('The server did not return a scan preview.'); @@ -141,6 +147,33 @@ export function ScanComposer(props: { Deep takes longer and costs more. + +