diff --git a/README.md b/README.md
index 502902e..7d02c9c 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**
@@ -130,7 +130,7 @@ Hand any finding to the conversation and have the model explain it.
-
+
## Configuration
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/docs/images/desktop-finding.png b/docs/images/desktop-finding.png
index 9875dc7..03f69a0 100644
Binary files a/docs/images/desktop-finding.png and b/docs/images/desktop-finding.png differ
diff --git a/docs/images/desktop-running.png b/docs/images/desktop-running.png
index 7bcad4a..9662e74 100644
Binary files a/docs/images/desktop-running.png and b/docs/images/desktop-running.png differ
diff --git a/docs/images/terminal-findings.png b/docs/images/terminal-findings.png
index 4d484d2..3a3c976 100644
Binary files a/docs/images/terminal-findings.png and b/docs/images/terminal-findings.png differ
diff --git a/scripts/ui-preview/main.tsx b/scripts/ui-preview/main.tsx
index 91cf263..5a8a7a9 100644
--- a/scripts/ui-preview/main.tsx
+++ b/scripts/ui-preview/main.tsx
@@ -65,11 +65,23 @@ const run = (id, status, focus) => ({
estimatedCostUsd: 0.24,
telemetry: {
activity: [
+ {
+ id: id + '-note',
+ kind: 'message',
+ status: 'completed',
+ label: 'Scan update',
+ detail:
+ 'The strongest candidate so far is a missing tenant check on project updates. Checking whether a member of another workspace can reach it.',
+ paths: [],
+ startedAt: new Date(t - 6000).toISOString(),
+ updatedAt: new Date(t - 5000).toISOString(),
+ },
{
id: id + '-reading',
kind: 'command',
status: status === 'running' ? 'running' : 'completed',
label: 'Reading source',
+ detail: 'sed -n "1,260p" src/api/projects.ts',
paths: ['src/api/projects.ts'],
worker: 1,
startedAt: new Date(t - 12000).toISOString(),
@@ -80,6 +92,7 @@ const run = (id, status, focus) => ({
kind: 'command',
status: 'completed',
label: 'Searching source',
+ detail: 'rg -n "tenantId" src/api',
paths: ['src/api'],
worker: 2,
startedAt: new Date(t - 14000).toISOString(),
diff --git a/scripts/ui-preview/test-hierarchy.py b/scripts/ui-preview/test-hierarchy.py
index 5db420c..4168de6 100644
--- a/scripts/ui-preview/test-hierarchy.py
+++ b/scripts/ui-preview/test-hierarchy.py
@@ -40,17 +40,21 @@ async def server_event():
await expect(page.get_by_role('heading', name='Reviewing source', exact=True)).to_be_visible()
await expect(page.locator('progress')).to_have_attribute('value', '3')
await expect(page.get_by_text('Partial estimate USD 0.24', exact=True).first).to_be_visible()
- await expect(page.locator('.gs-workers')).not_to_be_visible()
+ # Activity is open while a scan runs, and leads with the scan's latest note.
+ await expect(page.locator('.gs-workers')).to_be_visible()
+ await expect(page.locator('.gs-update')).to_contain_text('strongest candidate')
await expect(page.get_by_text('3 of 10 reviews', exact=True)).to_be_visible()
await shot('hierarchy-running-desktop')
- # Keyboard disclosure, then a server event: progress updates and the disclosure stays open.
+ # Closing it from the keyboard sticks across a server event; reopening works too.
await page.locator('.gs-activity > summary').focus()
await page.keyboard.press('Enter')
- await expect(page.locator('.gs-workers')).to_be_visible()
+ await expect(page.locator('.gs-workers')).not_to_be_visible()
await server_event()
await expect(page.locator('progress')).to_have_attribute('value', '4')
await expect(page.get_by_text('4 of 10 reviews', exact=True)).to_be_visible()
+ await expect(page.locator('.gs-workers')).not_to_be_visible()
+ await page.keyboard.press('Enter')
await expect(page.locator('.gs-workers')).to_be_visible()
await page.locator('.gs-info > summary').click()
await expect(page.get_by_text('Measured subtotal only', exact=False)).to_be_visible()
@@ -69,8 +73,10 @@ async def server_event():
await page.locator('.gs-coverage > summary').click()
await expect(page.get_by_text('Production identity-provider configuration was not available', exact=False)).to_be_visible()
+ # A running scan opens with only its activity showing.
await select('Review upload handling')
- assert await page.locator('.gs-detail details[open]').count() == 0
+ assert await page.locator('.gs-detail details[open]').count() == 1
+ await expect(page.locator('.gs-activity')).to_have_attribute('open', '')
assert await page.locator('progress').count() == 0
await expect(page.get_by_role('heading', name='Building threat model')).to_be_visible()
await page.get_by_role('button', name='New scan', exact=True).click()
@@ -105,7 +111,7 @@ async def server_event():
await page.locator('.gs-coverage > summary').click()
await expect(page.get_by_text('The scan did not report what it reviewed.', exact=True)).to_be_visible()
assert not errors, errors
- print(json.dumps({'passed': ['running stage and phase progress', 'collapsed diagnostics', 'keyboard disclosure', 'server event updates progress and keeps disclosure', 'selection resets disclosure', 'findings above fold', 'finding evidence', 'coverage details', 'unknown progress', 'new scan dismissal', 'cancel affects selected scan only', 'narrow no overflow', 'finalization', 'deep consolidation', 'failed scan', 'informational-only result', 'missing coverage', 'no browser errors'], 'screenshots': str(EVIDENCE)}))
+ print(json.dumps({'passed': ['running stage and phase progress', 'activity open while running', 'keyboard disclosure', 'server event updates progress and keeps disclosure', 'selection resets disclosure', 'findings above fold', 'finding evidence', 'coverage details', 'unknown progress', 'new scan dismissal', 'cancel affects selected scan only', 'narrow no overflow', 'finalization', 'deep consolidation', 'failed scan', 'informational-only result', 'missing coverage', 'no browser errors'], 'screenshots': str(EVIDENCE)}))
await browser.close()
diff --git a/src/core/run-telemetry.ts b/src/core/run-telemetry.ts
index 3f188de..9d9c6a0 100644
--- a/src/core/run-telemetry.ts
+++ b/src/core/run-telemetry.ts
@@ -17,6 +17,8 @@ export interface ScanActivityEntry {
kind: 'command' | 'tool' | 'reasoning' | 'message';
status: 'running' | 'completed' | 'failed';
label: string;
+ /** What the step said, for the user's own screen. Never returned to the session model. */
+ detail?: string;
paths: string[];
worker?: number;
startedAt: string;
@@ -133,6 +135,17 @@ function activityLabel(kind: ScanActivityEntry['kind'], description: string): st
if (executable === 'git') return 'Repository command';
return kind === 'tool' ? 'Tool call' : 'Command';
}
+/** One redacted line of what a step said. Opaque tool payloads are reduced to the tool's name. */
+function activityDetail(kind: ScanActivityEntry['kind'], description: string): string | undefined {
+ const text = safeReportText(description, 4000).replace(/\s+/g, ' ').trim();
+ if (!text) return;
+ if (kind === 'tool') {
+ const [name = '', payload = ''] = text.split(' · ', 2);
+ if (name === 'spawn_agent') return 'Starting a worker';
+ if (/\S{60,}/.test(payload)) return name;
+ }
+ return text.length > 400 ? `${text.slice(0, 399)}…` : text;
+}
export function recordActivity(
telemetry: ScanTelemetry,
value: CallbackValue<'onActivity'>,
@@ -159,6 +172,9 @@ export function recordActivity(
kind: value.kind,
status: value.status,
label: activityLabel(value.kind, value.description ?? ''),
+ ...(activityDetail(value.kind, value.description ?? '')
+ ? { detail: activityDetail(value.kind, value.description ?? '') }
+ : {}),
paths: safeActivityPaths(value.paths, repository),
...(worker === undefined ? {} : { worker }),
startedAt: previous?.startedAt ?? now,
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/RunActivity.tsx b/src/gui/RunActivity.tsx
index 2e66d97..844836e 100644
--- a/src/gui/RunActivity.tsx
+++ b/src/gui/RunActivity.tsx
@@ -3,6 +3,8 @@ import type { ScanState } from '../types';
import {
activityActor,
activityRows,
+ activityText,
+ latestUpdate,
activityStatusLabel,
deepProgressLabel,
dispatchLabel,
@@ -21,8 +23,15 @@ import { clockTime } from './format';
export function RunActivity(props: { run: ScanState }) {
const run = () => props.run;
return (
-
+ Activity
+
+