Pivot spec: ground the Inspector in a real claude process the user
selects, rather than in knobs.cc's own current working directory.
Closes the paradoxes described in
#11 (runtime
introspection — cli precedence slot + EnvVarsPanel ground truth)
and #12
(project / project_local resolved against knobs.cc's launch dir).
This file is the implementation contract for the pivot. Visual
reference and rail/list/drawer conventions stay in
inspector-ui.md; how the precedence layers
merge stays in settings-display.md. This
spec only adds the attach surface that those two now front-end
against.
Status of attach-mode phases lives in
roadmap.md. What's shipped vs. pending is tracked there.
Today knobs.cc reads file-based settings layers relative to its own
process CWD, reads the user's shell env from its own process env,
and renders the cli precedence row as a permanent empty state.
Each of those is a real product gap:
project/project_localreflect whichever directory knobs.cc was launched from — not the user's claude session. The rail rows are greyed out today as a stopgap (PrecedenceRail.tsx, shipped 2026-05-10).- The EnvVarsPanel's shell-env column reflects knobs.cc's process env, which can differ from the running claude's (Finder/Spotlight launches via LaunchServices vs. terminal claude with full shell env).
- The
cliprecedence row has no data source — flags passed toclaude(--model opus,--permission-mode auto, ...) override settings layers but can't be observed without reading another process's argv.
All three gaps are the same shape: knobs.cc needs to attach to a
running claude process and read its cwd, argv, and environ. With
those three values, every grounded layer becomes correct.
A separate option ("launch mode" — knobs.cc spawns claude itself) was considered and rejected for v1: it doesn't help users who launched claude from a terminal or IDE, which is the dominant workflow. Attach mode is a superset — sessions spawned by knobs.cc would attach the same way as any other.
The product reframes around grounded inspection:
- Primary view: the Inspector renders against a chosen claude
session. The rail's
project/project_localrows resolve to that session's cwd; theclirow populates from its argv; the EnvVarsPanel grounds its "shell" column in that session's environ. - Fallback view: when no claude is detected, the user picks a
project directory (
#12proposal 3 — "path picker mode"). The Inspector still works but cli/env stay ungrounded. - The "no session, knobs.cc's CWD as project" behavior is retired. That's the paradox; the pivot deletes it.
- For any local-host claude the user owns, surface a snapshot of its cwd, argv, and environ that the Inspector can ground itself against.
- Handle 0 / 1 / many claude processes coherently: 0 → path-picker fallback; 1 → auto-attach; many → picker.
- Keep the read-only boundary: attaching reads kernel-visible process state, never writes to the target process or anywhere on disk.
- Mask credential-shaped values in the attached environ by default (extend the existing EnvVarsPanel mask), with a UI toggle to reveal.
- Stay inside the Tauri 2 capability posture: no new JS-side plugin permissions beyond what attach mode strictly requires.
- Windows.
sysinfo::Process::environ()returns empty on Windows; cross-process env reading needsNtQueryInformationProcessReadProcessMemory(the PEB-walking path). Deferred per #11. Windows users see an "attach mode not available on this platform" empty state with the path picker still usable.
- Containerised / sandboxed claude. Host knobs.cc can't see into a container's PID namespace. Out of scope; honest empty state ("no inspectable claude processes").
- Remote / SSH claude.
sysinforeads local/procandsysctlonly. Headless / remote mode is a separate v-next conversation; see "Out of scope" below. - Root-owned claude. Same-UID requirement applies. If a claude process is running as root and the user isn't, we don't try to read it.
- Editing the attached claude's state. Read-only stays the
v1 boundary. No
kill, nosetenvinjection, no config writes triggered from the attach UI. - Polling beyond user-triggered refresh + window-focus. The data we read is frozen at exec time anyway; aggressive polling buys nothing.
Implementation lives in a new src-tauri/src/runtime.rs module.
Backed by the sysinfo crate.
sysinfo::System::processes() returns a map of every visible PID.
Filter to processes whose name() is claude or claude-code and
whose user_id() matches the calling user. Each surviving entry
becomes a ClaudeProcess.
For each surviving process:
| Field | macOS source | Linux source |
|---|---|---|
cwd |
proc_pidinfo PROC_PIDVNODEPATHINFO |
/proc/<pid>/cwd symlink |
argv |
KERN_PROCARGS2 sysctl |
/proc/<pid>/cmdline |
environ |
KERN_PROCARGS2 sysctl (env block) |
/proc/<pid>/environ |
started_at |
proc_pidinfo start_time |
/proc/<pid>/stat |
All of these are wrapped by sysinfo::Process::{cwd, cmd, environ, start_time}. No additional crates or unsafe code required at the
attach-mode layer.
New command registered alongside the existing handlers:
#[tauri::command]
fn read_runtime_layer() -> RuntimeSnapshot;Wire shape (snake-case at the IPC boundary, mirroring the existing
SettingsSnapshot convention):
interface RuntimeSnapshot {
// 0..N processes the user can pick from.
processes: ClaudeProcess[];
// The platform's capability status. "ok" on supported Unix; "unsupported"
// on Windows; "error" if sysinfo itself failed.
platform_status: "ok" | "unsupported" | "error";
// Set when platform_status === "error".
error: string | null;
}
interface ClaudeProcess {
pid: number;
// Seconds since UNIX epoch — the moment exec() ran for this process.
started_at: number;
cwd: string;
// Parsed argv (argv[0] = the binary, argv[1..] = flags + prompt).
argv: string[];
// Full environ as a flat object. Order is not preserved (rarely matters);
// duplicate keys collapse to the last occurrence.
environ: Record<string, string>;
}read_runtime_layer does not select a process. Selection is a
frontend-side concern: the UI persists the chosen pid in app
state, and read_settings_layers accepts that pid (or a fallback
path) as input — see "Wiring into existing commands" below.
The two existing commands take optional grounding hints so they can resolve against the chosen session rather than knobs.cc's CWD:
#[tauri::command]
fn read_settings_layers(
// The selected attach target. If supplied, the snapshot's
// project/project_local layers read from this process's cwd.
attached_pid: Option<u32>,
// Fallback when no claude is running and the user picked a dir.
// Mutually exclusive with attached_pid; if both are set,
// attached_pid wins.
project_root_override: Option<String>,
) -> SettingsSnapshot;Backwards compatibility: passing neither argument falls back to the current behavior (read relative to knobs.cc's CWD), so the existing test suite continues to pass during the migration. Once attach mode ships, the production UI never calls it that way — the fallback is retained only for test convenience and an explicit "browse the catalog cold" view that may land later.
read_shell_env_vars follows the same pattern — given an
attached_pid, it returns that process's environ projected through
the env-var catalog (so the EnvVarsPanel's "shell" column becomes a
"running claude env" column). The pid-less call still returns
knobs.cc's own process env, used by the path-picker fallback.
- On user action. A refresh button in the topbar + the existing
Rkeybinding (already wired forread_settings_layers) call both commands. - On window-focus. Tauri's
WindowEvent::Focused(true)fires the same refresh — common UX expectation when the user alt-tabs back from a terminal where they just started/stopped claude. - Not periodic polling. Process env, argv, and cwd are frozen at exec time. The only thing that changes is the set of running processes, and the focus-event + manual-refresh combo covers that without burning CPU/battery.
When the attached process disappears between refreshes (the user
killed claude in their terminal), the snapshot returns it with
status: "exited" and the UI surfaces a "session ended" affordance
in the topbar pill — see "UX states" below.
The Inspector's topbar grows a new session pill that's the primary attach affordance. It's the analogue of the existing managed-mcp / errors / diagnostics pills, but persistent (not conditional on data presence) and load-bearing for the inspector's ground-truth.
Four states total: three driven by process count, plus a brief loading state during the initial runtime-snapshot fetch on app boot.
-
Loading (initial fetch only).
- Pill:
detecting…with an empty status dot. - Renders for the few hundred ms between
App.tsxmounting andread_runtime_layerreturning. The inspector body shows its existing "reading settings…" spinner state during this window; the pill picks up its real state on the first successfulRuntimeSnapshot.
- Pill:
-
Zero claude processes detected.
- Pill:
no claude · pick a project ▾. - On open: path-picker fallback CTA — "pick a project directory to ground the inspector against, or start a claude session in a terminal and we'll pick it up automatically."
- Inspector renders against the picked path;
cliandenvrail rows are greyed; project / project_local resolve relative to the picker dir.
- Pill:
-
Exactly one claude process detected.
- Auto-attach. Pill:
attached · claude PID 4172 · ~/Projects/foo. - No picker required; the user can still click the pill to see the full process list (useful when a second session starts).
- All rail rows render normally;
clipopulates from argv; EnvVarsPanel grounds its shell column in the attached environ.
- Auto-attach. Pill:
-
Two or more claude processes detected.
- No auto-attach. Pill:
pick session ▾ (2 running). - On open: list of detected processes, each row showing pid, cwd, started-at, and the first ~3 argv entries. Clicking a row attaches; selection persists for the app's lifetime (no disk persistence in v1).
- Inspector renders against the chosen process. Rail rows identical to state 3.
- No auto-attach. Pill:
Plus an unsupported state for Windows (until #11 Proposal C lands): the pill reads "attach unsupported" with the path picker still usable; cli + env rail rows stay greyed; project files resolve against the picked directory.
Native folder picker via Tauri's dialog plugin. Adds a new
capability (dialog:default + dialog:allow-open) — the only
new JS-side capability the pivot needs. The grant is scoped to the
file-picker open primitive only; nothing else.
Justification: dragging or hand-typing a project path is meaningfully
worse UX than a native picker for an action this central to the
new flow, and dialog:allow-open is a narrow grant (read-only,
returns a path string — doesn't open or read the file/folder).
Considered alternatives, rejected:
- Plain text input ("paste a path"): no capability change but worse ergonomic, especially for users with deep monorepos.
- Drag-and-drop only: discoverable for some users, not for others; needs a fallback anyway.
- Tauri's
fsplugin: huge scope expansion for one read-only picker operation. Not worth it.
When the attached pid no longer exists in the next refresh:
- Pill state:
session ended · PID 4172 · re-attach ▾. - Inspector renders the last successful snapshot in dimmed state with a "session ended — re-attach to refresh" banner above the list. Better than wiping the view; the user was just looking at it.
- Clicking re-attach reopens the picker.
The existing EnvVarsPanel masks values whose key name matches
/key|token|secret|password/i to •••••••• abcd (last 4 chars).
That mask extends transparently to the attached environ — same
regex, same affordance, same reveal-on-click.
No explicit "enable runtime introspection" opt-in flow. Rationale:
reading another process's env is a same-UID operation — anything
knobs.cc can read this way, the user can already read with
ps -E or printenv. The mask-by-default + reveal-toggle pattern
is sufficient privacy hygiene, and aligns with how API-key
dashboards typically present credentials.
| Change | Surface | Notes |
|---|---|---|
sysinfo crate dep |
Rust only | No JS-side cap. Already proposed in #11. |
dialog:default capability |
JS | New grant. Scoped to the open-dialog primitive only. |
dialog:allow-open capability |
JS | New grant. Returns a path string; does not read files. |
No fs plugin |
(no change) | Path reads stay inside #[tauri::command] Rust functions. |
No shell / process plugin |
(no change) | We're reading another process, not spawning. Launch mode is not v1. |
| Tauri builder | Rust only | Register read_runtime_layer; add the window-focus listener that fires runtime-changed. |
The "no shell, no process, no dialog, no fs" rule from CLAUDE.md
and design-notes.md is relaxed in exactly one spot — dialog for
the path picker — and the rationale is documented above. No other
plugin grants needed for the pivot.
Both halves of the pivot shipped together on feat/attach-mode.
Closes #11 and #12.
- New
runtime.rsmodule +sysinfodep. - New
read_runtime_layerTauri command — same-UID claude process enumeration with cwd / argv / environ per process. Unit tests cover the filter logic; self-attach exercises the live-process path. read_settings_layersacceptsattached_pid/project_root_override;#[tauri::command(rename_all = "snake_case")]is required because Tauri 2 defaults to camelCase for command args while the rest of this project's wire format is snake_case.- Frontend:
SessionPilltopbar UI, three-state picker (0 / 1 / 2+ claudes), path-picker fallback via thedialog:allow-opencapability grant,R-key + window-focus refresh wiring. - Rail: project / project_local rows un-greyed when grounded; fall back to greyed when neither attached nor picked.
- New
catalog/cli-settings-map.json— hand-curated 5 entries (--model,--permission-mode,--effort,--agent,--add-dir). Schema:flag,settings(dotted path),kind(string|stringArrayMulti). Thestringkind consumes the next argv token (supporting both--flag valueand--flag=valueforms);stringArrayMultimirrors clap's<arg...>variadic — consumes tokens until the next--prefixed flag. cli_layer.rsparses argv against the map, emits a synthetic settings object as thecliLayerRead'sraw. Unmapped flags are skipped silently — claude has many flags this map doesn't cover, and erroring on unknown flags would poison the layer.env_layer.rsgrowsread_env_layer_attached(environ)— same parser asread_env_layer(), but reads the attached claude's environ instead of knobs.cc's own.settings.rs::read_snapshotroutes between the two based on attach state.- EnvVarsPanel: new
attachedcolumn (green badge) alongsideshell. Per-rowΔbadge when attached and shell values disagree. Two new filter chips:attachedandΔ diff. Mask + reveal extended to the new column.
The MERGED chip on array-merged paths originally fired whenever
the backend emitted elements, even when only one layer
contributed. That misleads users into thinking single-source
arrays are multi-sourced. Refined: when an array-merge path has
only one contributor, the row downgrades to state: "set" with
that layer as winner. elements stays populated so the drawer's
per-element list still renders. The MERGED chip only fires for
genuine multi-source merges.
These are real product surfaces that the pivot exposes but doesn't solve:
- Headless / CLI / server mode. Inspecting claude inside a Docker container, on an SSH-mounted server, or in a CI runner requires knobs.cc-the-Tauri-desktop-app to grow a sibling knobs.cc-the-data-collector binary that runs in those contexts and feeds a remote frontend. Real architectural shift; v-next.
- Persistent attach across sessions. When the attached claude exits and the user starts a new one with the same cwd, should knobs.cc auto-reattach? Probably yes, but the policy ("same cwd? same argv? same parent shell?") needs design.
- Multi-claude diff view. When N claudes are running, the picker shows them but doesn't let the user compare. Probably worth a dedicated "compare sessions" surface eventually.
- Recording snapshots for debug reports. "Export this snapshot as JSON" is a natural extension once the snapshot is grounded in a real session. Out of scope; doesn't change the data model.
- Picker UI shape if metadata grows. Topbar dropdown handles the 2-3-process case fine. If process metadata grows (live status, last-message timestamp, token usage), the dropdown may feel cramped — revisit as a modal or cmd+K quick-switcher then.
- Persistence of the selected project root. Currently
app-lifetime only (the pid is rightly transient; the path
picker resets on each launch when no claude is running). A
small
~/.config/knobs.cc/state.jsonfor "remember the last picked directory" is a v-next decision — flag if users start asking. - Watcher target.
notify-based file watcher inwatcher.rsstill watchescwd/.claude, not the attached / picked project dir. Manual refresh + window-focus cover it; dynamic rebinding is a focused follow-up if file edits to the attached project feel laggy in practice.
- #11 — runtime introspection (argv + env). Closed.
- #12 — project paths grounded in a real session. Closed.
settings-display.md— precedence merge semantics. Unchanged; this spec adds the grounding input the merge runs against.inspector-ui.md— rail/list/drawer layout. This spec adds the session pill; otherwise no UI shape changes.design-notes.md"Tauri 2 boundaries" — amended by thedialog:allow-opengrant for the path picker.