Skip to content

Commit 210fbb0

Browse files
committed
Add User scope toggle for variant runs
Runs default to `--setting-sources project` (project-only). The new topbar chip lets the user opt into `user,project`, which pulls in `~/.claude/CLAUDE.md`, slash commands from `~/.claude/skills/`, enabled plugins, and the env/permissions from `~/.claude/settings.json`. With user scope on, `--disable-slash-commands` is dropped so installed slash commands actually dispatch. Replaces the old `skillsEnabled` field; the schema migrates the old key forward. The rename reflects that this is all-or-nothing in `claude -p` — there is no finer knob that loads skills alone, so the toggle has to advertise the wider scope honestly. Folds in CLAUDE.md docs for both the user-scope flag gating and the write-mode --append-system-prompt landed in 9f7a280, since the two edits to the Spawn paragraph were physically interleaved.
1 parent 7b6c270 commit 210fbb0

10 files changed

Lines changed: 170 additions & 13 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Tests are plain tsx scripts, not a framework. Each file declares scenarios via a
6565
- Top-level entries of the user's project are mirrored by recursively walking the source tree, creating real directories on the sandbox side and **hardlinking individual files** (with a copy fallback on `EXDEV` when source and storage live on different filesystems). Hardlinks rather than symlinks because Claude Code's `Glob` and `Grep` are backed by `rg --files`, which skips symlinks without `--follow` — symlinked leaves would make the entire tree invisible to glob discovery. Filtering applies at every level: `HARD_EXCLUDED` (`.git`, `.claude`, `node_modules`, `.DS_Store`), root + nested `.gitignore`, the user's global git excludes file, source-side symlinks whose realpath escapes `cwd`, and symlink cycles.
6666
- Filtering subtleties — easy to regress when refactoring `sandbox.ts`: the ignore chain is walked **most-specific to least-specific** using `Ignore.test()` so a nested `!keep.log` overrides a root `*.log` (matches git's per-directory precedence — don't switch to `.ignores()`, which short-circuits and ignores negation). `Mirror.walk` records the **realpath of every directory it descends into** (not only symlink targets), so a symlink pointing back at a real-dir ancestor (`a/b/loop -> a`) is rejected on first encounter rather than after a wasted level of mirroring. Storage-root exclusion uses **realpath** (`realIsStorageRoot` against `classified.realTarget ?? <cwdReal>/<name>`), not the entry's path string — a top-level symlink like `alias -> .storage` would otherwise pass a name-based guard and let the mirror copy the sandbox's own state into a run dir.
6767
- In `write` mode, a `.claude/settings.json` allows `Write`/`Edit` only against `../outputs/**` (the per-run outputs dir, one level above the child's cwd). Read-only mode passes a tools allowlist of `Read,Glob,Grep,WebSearch,WebFetch`; write mode adds `Write,Edit`.
68-
3. **Spawn** (`src/server/runner.ts`) — invokes `claude -p <prompt> --output-format stream-json --include-partial-messages --verbose --model <m> --tools <list> --allowedTools <list> --strict-mcp-config --setting-sources project --disable-slash-commands`. The runner **strips** `NODE_OPTIONS`, `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_COMMON_DIR`, `GIT_CEILING_DIRECTORIES`, `CLAUDE_PROJECT_DIR`, `CLAUDE_PROJECT_NAME` from the spawn environment so a parent shell can't override the planted sandbox. `HOME` / `CLAUDE_CONFIG_DIR` are kept so the child reads the user's auth.
68+
3. **Spawn** (`src/server/runner.ts`) — invokes `claude -p <prompt> --output-format stream-json --include-partial-messages --verbose --model <m> --tools <list> --allowedTools <list> --strict-mcp-config --setting-sources <scope>` and (when user scope is off) `--disable-slash-commands`. The two trailing flags are gated by `session.userScopeEnabled` (topbar "User scope" chip): off by default ⇒ `--setting-sources project --disable-slash-commands`; on ⇒ `--setting-sources user,project` and the disable flag is dropped. `user,project` is all-or-nothing in `claude -p` — there's no finer knob that loads `~/.claude/skills/` alone, so flipping it on also injects the user CLAUDE.md, enabled plugins (skills/hooks/agents), and the env/permissions blocks from `~/.claude/settings.json`. Naive removal of `--disable-slash-commands` looks like it should re-enable installed skills but doesn't: the user setting source has to be widened too. When `mode === 'write'`, the runner additionally passes `--append-system-prompt <WRITE_MODE_SYSTEM_PROMPT>` (text lives in `src/shared/constants.ts`) telling the child to mirror source paths under `../outputs/<rel>` and write full files — without this nudge, models often recognize the `Write(**)`/`Edit(**)` deny rule from the planted `.claude/settings.json` and bail out ("I cannot apply these fixes") instead of producing modified copies in the outputs dir. Confounder to watch for when designing a write-mode variant: if the variant's own content prescribes a different output convention (e.g. "place results in `results/`"), it conflicts with this directive — that's the first place to look if write-mode A/B results seem off. The runner **strips** `NODE_OPTIONS`, `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_COMMON_DIR`, `GIT_CEILING_DIRECTORIES`, `CLAUDE_PROJECT_DIR`, `CLAUDE_PROJECT_NAME` from the spawn environment so a parent shell can't override the planted sandbox. `HOME` / `CLAUDE_CONFIG_DIR` are kept so the child reads the user's auth.
6969
4. **Stream parsing** (`src/server/claudeStream.ts`) — line-buffers stdout into Anthropic stream events. Normalizes them into `NormalizedEvent`s (see `src/shared/schemas/events.ts`). Two non-obvious rules:
7070
- **Turn counting:** increment on every `message_stop` where `currentMessageRole === 'assistant'`. Tool-use stops count too, so the live counter advances on each intermediate assistant message — useful as a "model is doing work" heartbeat. Partial deltas never count.
7171
- **Tool result pairing:** `tool_result` events arrive in a later (user) message and may be reordered relative to their `tool_use` blocks. Pair by `tool_use_id`, not by recency (issue #5).

src/server/routes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ export function createRouter(deps: RouteDeps) {
141141
}
142142
s.judgeModel = next;
143143
}
144+
if (body.userScopeEnabled !== undefined)
145+
s.userScopeEnabled = Boolean(body.userScopeEnabled);
144146
if (body.defaultModel !== undefined) s.defaultModel = String(body.defaultModel);
145147
});
146148
return json(res, 200, updated);
@@ -262,6 +264,7 @@ interface PatchSessionBody {
262264
mode?: string;
263265
judgeEnabled?: boolean;
264266
judgeModel?: string;
267+
userScopeEnabled?: boolean;
265268
defaultModel?: string;
266269
}
267270

src/server/runManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ export class RunManager extends EventEmitter {
352352
model: col.model,
353353
effort: col.effort ?? defaultEffortForModel(col.model),
354354
mode: sessionSnap.mode as Mode,
355+
userScopeEnabled: sessionSnap.userScopeEnabled,
355356
initialConfig,
356357
});
357358

src/server/runner.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export interface RunnerInput {
3232
model: string;
3333
effort?: Effort | null; // null/undefined → omit --effort
3434
mode: Mode;
35+
// When true, widens --setting-sources to `user,project` and drops
36+
// --disable-slash-commands. See buildArgs() for the full trade-off.
37+
userScopeEnabled?: boolean;
3538
allowedTools?: string[]; // defaults based on mode
3639
caps?: { turns?: number; wallClockMs?: number };
3740
env?: NodeJS.ProcessEnv;
@@ -425,9 +428,15 @@ export class Runner extends EventEmitter {
425428
toolsStr,
426429
'--strict-mcp-config',
427430
'--setting-sources',
428-
'project',
429-
'--disable-slash-commands',
431+
// `user,project` is all-or-nothing in `claude -p` — widening also pulls
432+
// in the user CLAUDE.md, plugins, env, and permissions from settings.json.
433+
this.input.userScopeEnabled ? 'user,project' : 'project',
430434
];
435+
// With project-only sources the only slash commands are CLI built-ins;
436+
// disable so they don't dispatch and make runs non-deterministic.
437+
if (!this.input.userScopeEnabled) {
438+
args.push('--disable-slash-commands');
439+
}
431440
// Write mode: nudge the child to mirror source paths under ../outputs/
432441
// instead of bailing on the sandbox's Write(**)/Edit(**) deny rule.
433442
if (this.input.mode === 'write') {

src/shared/schemas/session.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,30 @@ export const ColumnConfigSchema = z.object({
1717
});
1818
export type ColumnConfig = z.infer<typeof ColumnConfigSchema>;
1919

20-
export const SessionFileSchema = z.object({
21-
mode: ModeSchema,
22-
judgeEnabled: z.boolean(),
23-
// Concrete model ID (not an alias) used by the Haiku-by-default judge subprocess.
24-
// Optional+default keeps pre-existing session.json files (without this field) parsing cleanly.
25-
judgeModel: ModelIdSchema.optional().default('claude-haiku-4-5'),
26-
defaultModel: ModelIdSchema,
27-
cwd: z.string(),
28-
columns: z.array(ColumnConfigSchema).min(1).max(3),
29-
});
20+
// `skillsEnabled` was renamed to `userScopeEnabled`; copy the old key forward
21+
// so existing session.json files keep the user's prior choice.
22+
export const SessionFileSchema = z.preprocess(
23+
(data) => {
24+
if (data && typeof data === 'object' && !Array.isArray(data)) {
25+
const obj = data as Record<string, unknown>;
26+
if ('skillsEnabled' in obj && !('userScopeEnabled' in obj)) {
27+
const { skillsEnabled, ...rest } = obj;
28+
return { ...rest, userScopeEnabled: skillsEnabled };
29+
}
30+
}
31+
return data;
32+
},
33+
z.object({
34+
mode: ModeSchema,
35+
judgeEnabled: z.boolean(),
36+
// Optional+default keeps pre-existing session.json files (without this field) parsing cleanly.
37+
judgeModel: ModelIdSchema.optional().default('claude-haiku-4-5'),
38+
userScopeEnabled: z.boolean().optional().default(false),
39+
defaultModel: ModelIdSchema,
40+
cwd: z.string(),
41+
columns: z.array(ColumnConfigSchema).min(1).max(3),
42+
}),
43+
);
3044
export type SessionFile = z.infer<typeof SessionFileSchema>;
3145

3246
export const MAX_COLUMNS = 3;
@@ -37,6 +51,7 @@ export function makeDefaultSession(cwd: string): SessionFile {
3751
mode: 'read-only',
3852
judgeEnabled: true,
3953
judgeModel: 'claude-haiku-4-5',
54+
userScopeEnabled: false,
4055
defaultModel,
4156
cwd,
4257
columns: [makeBlankColumn('col-1', defaultModel), makeBlankColumn('col-2', defaultModel)],

src/web/App.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import { VariantColumn } from './components/VariantColumn.js';
2323
import { Footer, REPO_URL } from './components/Footer.js';
2424
import { Hint } from './components/Hint.js';
25+
import { UserScopeHint } from './components/UserScopeHint.js';
2526

2627
type LiveEvent =
2728
| { kind: 'partial'; streamKind: 'text' | 'thinking'; chunk: string }
@@ -598,6 +599,16 @@ export function App(): JSX.Element {
598599
}
599600
}, []);
600601

602+
const onToggleUserScope = useCallback(async () => {
603+
if (!state.session) return;
604+
try {
605+
const s = await patchSession({ userScopeEnabled: !state.session.userScopeEnabled });
606+
dispatch({ type: 'session-patched', payload: s });
607+
} catch (err) {
608+
dispatch({ type: 'error', message: (err as Error).message });
609+
}
610+
}, [state.session]);
611+
601612
const onStartNewConfirm = useCallback(async () => {
602613
dispatch({ type: 'set-confirm-start-new', open: false });
603614
try {
@@ -650,6 +661,15 @@ export function App(): JSX.Element {
650661
</div>
651662
</div>
652663
</div>
664+
<Hint content={<UserScopeHint />}>
665+
<button
666+
className="chip"
667+
aria-pressed={session.userScopeEnabled}
668+
onClick={onToggleUserScope}
669+
>
670+
User scope: {session.userScopeEnabled ? 'On' : 'Off'}
671+
</button>
672+
</Hint>
653673
<span className="spacer" />
654674
<span style={{ color: 'var(--fg-dim)', fontFamily: 'var(--mono)', fontSize: 11 }}>
655675
{session.cwd}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { JSX } from 'react';
2+
3+
export function UserScopeHint(): JSX.Element {
4+
return (
5+
<div className="user-scope-hint">
6+
<p>
7+
<strong>Off (default)</strong> — Project-scope only. Variants run without your user-level
8+
settings, skills, plugins, or CLAUDE.md.
9+
</p>
10+
<p>
11+
<strong>On</strong> — Adds the user setting source:
12+
</p>
13+
<ul>
14+
<li>~/.claude/CLAUDE.md</li>
15+
<li>~/.claude/skills/* (slash commands)</li>
16+
<li>Enabled plugins (skills, hooks, agents)</li>
17+
<li>Permissions + env from settings.json</li>
18+
</ul>
19+
<p className="dim">Per-user reproducible only.</p>
20+
</div>
21+
);
22+
}

src/web/lib/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export async function patchSession(body: {
7272
mode?: string;
7373
judgeEnabled?: boolean;
7474
judgeModel?: string;
75+
userScopeEnabled?: boolean;
7576
defaultModel?: string;
7677
}): Promise<SessionFile> {
7778
return request<SessionFile>('/api/session', {

src/web/styles.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,19 @@ body {
620620
padding: 0 4px;
621621
border-radius: 2px;
622622
}
623+
.user-scope-hint p {
624+
margin: 0 0 6px;
625+
}
626+
.user-scope-hint p:last-child {
627+
margin-bottom: 0;
628+
}
629+
.user-scope-hint ul {
630+
margin: 0 0 6px;
631+
padding-left: 16px;
632+
}
633+
.user-scope-hint .dim {
634+
color: var(--fg-dim);
635+
}
623636

624637
.add-column {
625638
flex: 0 0 auto;

test/runner.smoke.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,6 +1308,79 @@ await scenario(
13081308
},
13091309
);
13101310

1311+
await scenario(
1312+
'user-scope toggle: session.userScopeEnabled flows through RunManager to the spawn argv',
1313+
async () => {
1314+
for (const enabled of [false, true] as const) {
1315+
const cwd = await mkdtemp(join(tmpdir(), `mdredd-user-scope-${enabled}-`));
1316+
const storageRoot = join(cwd, 'agents', 'mdredd');
1317+
const dumpPath = join(cwd, 'argv.txt');
1318+
const savedScenario = process.env.FAKE_CLAUDE_SCENARIO;
1319+
const savedDump = process.env.FAKE_CLAUDE_DUMP_ARGS;
1320+
process.env.FAKE_CLAUDE_SCENARIO = 'happy';
1321+
process.env.FAKE_CLAUDE_DUMP_ARGS = dumpPath;
1322+
try {
1323+
const session = await SessionStore.load(storageRoot, cwd);
1324+
await session.mutate((s) => {
1325+
s.userScopeEnabled = enabled;
1326+
s.judgeEnabled = false; // unrelated to this test; skip the extra spawn
1327+
const col = s.columns[0]!;
1328+
col.variantName = `user-scope-${enabled}`; // explicit name skips slug spawn
1329+
col.variantContent = '# variant\n';
1330+
col.prompt = 'do a thing';
1331+
});
1332+
const runManager = new RunManager({ claudeBin: fakeBin, cwd, storageRoot, session });
1333+
await runManager.init();
1334+
const cfg = await runManager.startColumn('col-1');
1335+
const deadline = Date.now() + 10_000;
1336+
while (runManager.isColumnActive('col-1')) {
1337+
if (Date.now() > deadline) throw new Error('run did not finalize within 10s');
1338+
await new Promise((r) => setTimeout(r, 25));
1339+
}
1340+
const finalCfg = JSON.parse(
1341+
await readFile(join(storageRoot, cfg.runFolder, 'config.json'), 'utf8'),
1342+
) as { status: string };
1343+
if (finalCfg.status !== 'completed') {
1344+
throw new Error(
1345+
`userScopeEnabled=${enabled}: expected completed, got ${finalCfg.status}`,
1346+
);
1347+
}
1348+
const argv = (await readFile(dumpPath, 'utf8')).split('\n');
1349+
const idx = argv.indexOf('--setting-sources');
1350+
const expectedScope = enabled ? 'user,project' : 'project';
1351+
if (idx < 0 || argv[idx + 1] !== expectedScope) {
1352+
throw new Error(
1353+
`userScopeEnabled=${enabled}: expected --setting-sources ${expectedScope}, got ${argv[idx + 1]}`,
1354+
);
1355+
}
1356+
const hasDisable = argv.includes('--disable-slash-commands');
1357+
if (enabled && hasDisable) {
1358+
throw new Error(
1359+
`userScopeEnabled=true: --disable-slash-commands should be absent, got argv: ${argv.join(' ')}`,
1360+
);
1361+
}
1362+
if (!enabled && !hasDisable) {
1363+
throw new Error(
1364+
`userScopeEnabled=false: --disable-slash-commands should be present, got argv: ${argv.join(' ')}`,
1365+
);
1366+
}
1367+
// Isolation flags survive the toggle change.
1368+
if (!argv.includes('--strict-mcp-config')) {
1369+
throw new Error(
1370+
`userScopeEnabled=${enabled}: --strict-mcp-config missing, got argv: ${argv.join(' ')}`,
1371+
);
1372+
}
1373+
} finally {
1374+
if (savedScenario === undefined) delete process.env.FAKE_CLAUDE_SCENARIO;
1375+
else process.env.FAKE_CLAUDE_SCENARIO = savedScenario;
1376+
if (savedDump === undefined) delete process.env.FAKE_CLAUDE_DUMP_ARGS;
1377+
else process.env.FAKE_CLAUDE_DUMP_ARGS = savedDump;
1378+
await rm(cwd, { recursive: true, force: true });
1379+
}
1380+
}
1381+
},
1382+
);
1383+
13111384
await scenario(
13121385
'write mode: appends system prompt directing outputs to ../outputs/<rel>',
13131386
async () => {

0 commit comments

Comments
 (0)