Oh My Claude
Claude Code, native inside dsh
Drive your logged-in Claude Code CLI as a first-class dsh provider: no API key, no extra services, no runtime dependencies.
Built with AI assistance, and open to more. Contributions from AI coding agents are welcome; start with the Developing section below.
Claude Code CLI as an LLM provider for dsh. Every request drives claude -p with stream-json in and out, so it uses whatever login, hooks, CLAUDE.md files, MCP servers and rate limits Claude Code already has. No API key needed. The plugin speaks the CLI's own protocol (the one the Agent SDK wraps) directly, so it has no runtime dependencies.
Tour

The shield is dsh's own control; in a Claude session its rows become Claude's six permission modes, each labelled with the dsh access level it sets underneath.

One ✻ button beside the composer opens the whole plugin: Memory, Instructions, Rewind, Changes, MCP, Asides, Diagnostics, Tasks and Tune, plus a Restore tab while the session is still blank.


Cost and cached token count, figures dsh cannot compute, join dsh's footer stats row.

Plan usage and the CLI's own context breakdown live in dsh's context ring popover, and on a phone the panel becomes a sheet. Captured by tools/playwright/tour.ts.

Once an SSH box is saved, dsh's own Add workspace dialog gains a box dropdown: a folder on that box becomes a workspace here, and the session in it runs Claude Code there.
Install
Needs the Claude Code CLI on PATH and already logged in (claude --version works, claude opens without asking you to sign in). Nothing else: no API key, no Node build step.
Requires dsh 0.1.5 or newer.
dsh plugin --profile web add dsh-oh-my-claude # from npm
dsh plugin --profile web add github:lcestou/dsh-oh-my-claude # or straight from the repository
systemctl --user restart dsh-web.service # or restart `dsh web` however you run it
The package declares a dsh bundle, so dsh plugin add registers it in the profile by itself. After the restart, "Oh My Claude" appears in the model picker with the models your login can use. Pick one and chat. Later, dsh plugin --profile web update dsh-oh-my-claude and the same restart bring in a new version.
Optional, in ~/.dsh/settings.yaml:
agent-default-model: # make Claude Code the default for new sessions
provider: claude-code
model: claude-fable-5-1
subagent-model-selection: # let dsh subagents run on Claude Code too
enabled: true
allowedModels:
- provider: claude-code
model: claude-haiku-4-5
Plugin settings live under Settings → Oh My Claude, or as config: on the bundle row if you override it in the profile's cordis.patch.yml.
Upgrading dsh to 0.1.5 or later
Two things change under this plugin:
The session log gains a versioned format with a strict migration. Logs written with
toolsInline: falsebefore 2026-09-08 hold rawtool/callrows the migration refuses, and dsh then shows Failed to load history … does not match one advertised tool call for that session. Repair them once, with dsh-web stopped:bun tools/dsh-session-repair.ts --check --all # lists what needs repair, changes nothing bun tools/dsh-session-repair.ts --apply --all # drops the offending rows, keeps a .bak next to each logRun it from a checkout of this repository:
tools/is not part of the installed package.The tool proves every repaired log through dsh's own migration chain before writing it. Conversation text is untouched; only the tool cards of those old turns are gone from history.
Developing
Install from a checkout instead: dsh plugin --profile web add link:/path/to/oh-my-claude. The source is strict TypeScript under src/; dsh loads the compiled output in lib/, so run bun run build after every edit. lib/client.js hot-reloads into every open tab the moment it is written; a change under lib/server needs a restart of dsh web. lib/server comes from tsc, lib/client.js from bun build of src/client/index.tsx; both are committed, so a plain install has them. bun run validate runs the whole gate: format, lint (oxlint with the anti-slop rules in tools/oxlint), tests, dead code, build, typecheck and a conflict-marker scan.
Lint contract, read before writing code (the anti-slop rules in .oxlintrc.json fail the build and cost a worker 25 check runs on 2026-09-05):
- Every
asassertion needs a// SAFETY: <the invariant that makes it true>comment on the line directly above it. Prefer a type guard or a narrower type so no assertion is needed. Noas X as Ychains, noas unknown as. - No
typeof x === "string"style runtime checks insrc/adapter.tsandsrc/client/index.tsx(test files and the listed server modules are exempt). Use the existing narrowing helpers (isJsonObject,textOf, schema parsing) or a typed field. - No conditional empty-object spread (
...(cond ? { a } : {})). Assign the property in a separate statement when present. - No
unknownparameters, returns or type aliases insrc/adapter.ts; name the shape. - Do not widen a known literal (
const x: string = "bash"); let inference keep the literal. - No
_prefixedidentifiers (no-underscore-dangle), no shadowed names (no-shadow), no unused variables. - Exemptions in
.oxlintrc.jsonare per rule, not per file. The I/O modules (process.ts,sessions.ts,state.tsand the rest) parse payloads this plugin does not own, so four of the anti-slop rules are turned off for them, but each rule names only the files that actually need it, not one blanket list. Turning all four off everywhere hid 19 file-and-rule pairs that pass without help. Before adding a file to one of those lists, check it fails without it. - TypeScript, always. Every new script, tool and helper is
.ts,tools/included. No.js, no.mjs, and no JSDoc types standing in for real ones: JSDoc is checked by nothing here and drifts silently, and one stray.mjsleaves the next reader working out which rules apply to which file. If something genuinely has to be served raw as JavaScript, say why at the top of it. - Semantic ids and roles in markup. Client code gives what it emits a stable, meaningful
idordata-*hook (data-omc-turn-status, not a generated class) and the right ARIA role, and selects on those. dsh's own DOM is not ours to depend on: a hashed class name changes on any dsh upgrade, and a check that selects one then fails for a reason unrelated to this plugin. - Run
bun run validateafter each edit, not once at the end: the first run tells you which rule you are fighting, and it formats in place before it checks anything. - The suite runs under a fresh
DSH_OMC_STATE_DIR(thetestscript sets it), andSTATE_DIRfollows it, so a test never rewrites the running plugin's files under~/.local/state/dsh-oh-my-claude. Any new store must build its path fromSTATE_DIR, never fromhomedir()on its own.
Client bundle safety: dsh hot-reloads lib/client.js the moment bun run build writes it, into every open tab. A wrong service name in export const inject leaves the plugin pending (waiting for service: …) and every panel it owns disappears (2026-09-05: models instead of modelDirectories). After any client build, run the headless check and read its first line:
TOKEN=$(grep -o 'token=[A-Za-z0-9_-]*' ~/.local/state/dsh/web.log | tail -1 | cut -d= -f2)
PLAYWRIGHT_ROOT=/path/to/a/project/with/playwright bun tools/playwright/peek.ts "$TOKEN"
It prints the sidebar text (a Failed to load plugins line means roll back with git show main:lib/client.js > lib/client.js and rebuild). tools/playwright/turn-status.ts <token> <out.png> and restore-button.ts <token> <out.png> screenshot the two DOM features; starter-row.ts <token> types into a blank session and fails if the starter dock changes height when the Save draft chip appears; keyword-paint.ts <token> types ultracode and ultrathink into the composer and reads the highlight registry back. These are development checks only; nothing in the plugin needs Playwright, which is why it is not a dependency: tools/playwright/pw.ts resolves the borrowed install at run time and declares the slice of its API these scripts use.
Everything under tools/ is TypeScript (the scripts run with bun, the oxlint plugin is loaded by oxlint), and tools/**/*.ts is in the tsconfig.json include, so bun run typecheck covers all of it. That is what keeps a rename in src/ from leaving live-cli-check.ts probing the wrong thing, and a null boundingBox() from reaching a screenshot crop.
Configuration
All keys are optional.
| Key | Default | Meaning |
|---|---|---|
command |
claude |
Claude Code binary: a name on PATH or an absolute path. |
spawn |
keeper |
How the process starts. keeper: under a small keeper process outside dsh's process tree (its own systemd user scope when systemd-run exists, else a detached process), so a dsh restart leaves Claude running and the new dsh reattaches; see Restarts. node: directly, as dsh's child. dsh: through dsh's subprocess seam (ctx.subprocess). With a remote provider mounted on that seam, a remote workspace then runs Claude Code on that machine; the seam scrubs credential-shaped env vars (KEY/TOKEN/SECRET/PASSWORD), so log in on the machine that runs it. |
sshHost |
`` | Drive this instance's Claude Code on a remote host over SSH ([user@]host, or a Host alias from ~/.ssh/config). This box's harness runs claude there, nothing else runs on the far side, and the stream flows through the ssh pipe. Uses the remote's own ~/.claude login, so the status, diagnostics, Settings and Tune panels report and edit that box; forces node-style spawn (no keeper survival yet). The dsh MCP bridge and the remaining file-reading tabs (Browser, Memory, Rewind) do not reach the remote yet, so keep dshTools off. Key-based auth only (BatchMode); a missing key fails fast rather than prompting. See "A box over SSH". |
permissionMode |
dsh |
Claude Code permission mode for the tools it runs itself. dsh follows the session's access-mode switch in the dsh UI: read-only → plan, workspace-write → acceptEdits, danger-full-access → bypassPermissions. Any of the CLI's six (plan, manual, acceptEdits, auto, dontAsk, bypassPermissions) pins it for every session; the shield by the composer still narrows a session below that ceiling. |
allowedTools |
[] |
Extra --allowedTools entries. |
disallowedTools |
[] |
--disallowedTools entries. |
addDirs |
[] |
Extra --add-dir directories. |
pluginDirs |
[] |
Local plugin directories (or .zip files) loaded for this session only, as repeatable --plugin-dir. Session-scoped, so they write no settings and do not show in the roster; a CLI without the flag leaves them off, and a path the CLI's policy refuses surfaces its own refusal. |
pluginUrls |
[] |
Plugin .zip URLs fetched for this session only, as repeatable --plugin-url. Same session scope as pluginDirs. |
maxTurns |
unset | --max-turns cap per request. |
maxBudgetUsd |
unset | --max-budget-usd cap per request. |
titleModel |
haiku |
Model used for dsh's session-title requests. |
toolActivity |
true |
Show Claude Code tool calls and results. |
toolsInline |
true |
Render tool activity inline in the stream. false appends dsh's own tool/call rows instead. The Tune tab's Tool activity switch overrides this once set. Rows are only written where they load back: a format-0 session log (dsh before 0.1.5), or a dsh whose loader takes a tool/call no assistant message advertised, which the plugin probes at startup (dsh 0.1.5-rc.1 does; its format migration did not). |
hookRows |
true |
Show Claude Code hook starts and results as reasoning lines (adds --include-hook-events). |
resume |
true |
Keep one Claude Code session per dsh session. |
idleTimeoutMs |
1800000 |
Kill the child when no stream event arrives for this long; surfaces as IDLE_TIMEOUT. |
toolTextLimit |
600 |
Characters of a tool result kept in its session row. |
debug |
false |
Log the spawn arguments (prompt redacted) and cwd per call. |
approvals |
true |
Relay Claude Code permission prompts and AskUserQuestion to dsh dialogs. |
processIdleMs |
1800000 |
Kill a session's idle Claude process after this long without a turn. |
maxProcesses |
4 |
Cap on live Claude processes; the longest idle is evicted first. |
fastMode |
false |
Launch every Claude process with fast mode on (--settings '{"fastMode":true}', Opus only, higher cost). The bridged /fast then toggles it for that session; in headless mode the toggle only works when the session started this way. |
commandBridge |
true |
Register Claude Code's slash commands (skills, custom commands, from the CLI's init frame) as dsh /commands that hand the line to Claude. dsh's own command of the same name wins. |
redactSecrets |
true |
Mask values of env vars named *KEY, *TOKEN, *SECRET, *PASSWORD or *CREDENTIAL (8+ chars) in Claude's tool results as [redacted:NAME] before dsh sees them. |
persistTodos |
true |
Re-append the last todo list at each turn start so dsh's panel keeps it. |
continueAfterLimit |
true |
When a usage limit ends a turn, wait for the reset and continue the task on its own, as the CLI does. |
configDir |
`` | Claude Code config dir for this plugin instance (exported as CLAUDE_CONFIG_DIR to every spawned CLI process); empty = the env var or ~/.claude. Moves transcripts, settings.json and .credentials.json together, groundwork for multi-account mounts. |
ownTranscripts |
false |
Keep this instance's transcripts in the plugin's own state dir instead of ~/.claude/projects/. The CLI runs against a mirror config dir whose login, settings, commands and skills are symlinks back to the real ~/.claude, so only projects/ diverges; the archive reads both, so a session started from a terminal is still listed. |
providerId |
claude-code |
Provider id in the model picker. The default is claude-code; anything starting with claude-code- (e.g. claude-code-work) mounts a second independent instance with its own login, state and process registry. |
providerName |
`` | Display name in the model picker. Empty = "Oh My Claude" for the default id, else "Oh My Claude (<suffix>)" where suffix is the part after claude-code-. |
dshTools |
true |
Serve dsh tools (subagents, jobs, goals, skills, web search) to Claude Code over MCP. |
Effort: none is advertised as default, so Claude Code's own default applies unless you pick one in dsh. Claude Code's own subagents stream back as ↳ subagent reasoning blocks. Tool calls the CLI denies because it cannot prompt are counted and reported in one line at the end of the turn; under auto, the calls its classifier blocks get their own line with the reasons it gave (Exfil Scouting, Code from External).
How it works
Models. The picker is filled from the Anthropic Models API, using ANTHROPIC_API_KEY if set, otherwise the access token Claude Code stores in ~/.claude/.credentials.json. Cached ten minutes; the hardcoded list in src/adapter.ts is the fallback. Context window and effort levels come from the same response, and picking an effort in dsh maps to --effort.
settings.json then gets the same say the terminal gives it, read fresh on each listing: modelPicker.options adds its rows (dropping the built-in lineup when replaceBuiltInOptions is set) and availableModels allows only the families, versions or ids it names, with the Default row surviving either way. Only the listing is filtered: a session already on a model the allowlist excludes keeps resolving it, as the terminal does.
The API dates some ids (claude-haiku-4-5-20251001) and leaves others alone, while the fallback list and the CLI's picker use the undated form; since dsh keys a model by its id, an API id whose undated form is one the fallback list names is advertised undated. Otherwise every switch between the API and the fallback retired the model you had enabled and offered an unselected copy of it. An id the fallback list does not name keeps whatever the API called it. The CLI's own picker is held to the same rule: its rows lead the lineup under the CLI's labels, windows and effort levels, but a row landing on a model the catalog already knows takes that model's id rather than the alias, so a model is spelled the same before and after the CLI answers list_models. Only default and the [1m] variants keep an alias, having no stable id to take.
Sessions. Each dsh session gets a deterministic Claude Code session id. The first request starts it with --session-id; later requests find the transcript under ~/.claude/projects/<cwd>/ and pass --resume, sending only the new turn. Reopening an old dsh session resumes the same Claude Code session, with all its tool history. Forked sessions start fresh from the full dsh transcript. The child runs in the dsh session's working directory, so Claude Code sees the right CLAUDE.md and project files. A terminal claude opened in that same directory lists them under /resume like its own. That takes one thing from the plugin: every child runs with CLAUDE_CODE_ENTRYPOINT=dsh-oh-my-claude, because a print-mode CLI otherwise records entrypoint: sdk-cli and the picker hides every sdk-cli, sdk-ts and sdk-py session (Claude Code 2.1.268). Sessions recorded before this setting stay hidden in the picker; claude --resume <session id> still opens them, the id being the transcript's file name under ~/.claude/projects/<cwd>/. Enter the directory by its real path, since a symlinked path maps to a different transcript folder.
One session, two places. A dsh session and a terminal claude /resume share one transcript file. Carrying a session between the two needs nothing switched on: Claude Code writes that file itself, so a terminal /resume opens with dsh's turns in it, and a session that has only ever run in a terminal is seeded into dsh from the same transcript. What is optional is the live copy in one direction: terminal exchanges appearing in an already-open dsh tab as they land. That is the Terminal mirror, marked experimental in the settings panel and off by default, because it holds a dsh turn open while it fills and a prompt typed meanwhile can queue behind it. Turning it off does not affect moving a session between dsh and a terminal. With it on, the plugin watches the transcript of every session dsh has loaded (inotify on this box, a stat over the shared ssh connection every 30 seconds for a session that runs on an SSH box). The terminal's rows carry entrypoint: cli, the plugin's own carry dsh-oh-my-claude, so when a terminal exchange lands and the file settles the plugin opens a turn of its own in the dsh session: the prompt as a user message, the reply as the assistant's, tool calls drawn inline the way a live turn draws them. Nothing goes to Claude for it, since the transcript already holds both sides, and the live Claude process is marked stale so the next real message resumes from the transcript with the terminal turns in context. One exchange per turn, in order; an exchange that lands during a dsh turn follows it; an archived session is left alone until it is opened again. Where each watch stands is kept in watch.json under the state directory, so a restart carries on where it left off, and resume.log records each watch and mirror. What that gives, side by side:
| Start here | Continue there | What happens |
|---|---|---|
| dsh | terminal claude /resume, same directory |
The picker lists the session; it opens with the whole history. |
| terminal | dsh, the session open in a tab | Each exchange shows in the tab within seconds; the next dsh message knows it. |
| terminal | dsh, the session not open | Shows the moment the session is opened; nothing opens by itself. |
| terminal only | dsh, never spoken there | The panel's session list offers the transcript; opening it seeds a dsh session that keeps the same Claude id. |
| dsh | terminal, while both are open | The terminal never re-reads the file: it sees dsh's turns only on its next /resume. |
The last row is the one limit of the design, and it comes from Claude Code, which follows the chain the transcript's last row belongs to when it resumes: a terminal that keeps typing after a dsh turn forks the file, and the next dsh turn takes that fork as the truth. One side live at a time is the rule.
Temporary sessions. Type /temporary in a session to toggle it: from the next turn its Claude process runs with --no-session-persistence, so nothing lands under projects/ for it, and the session is never resumed on the Claude side; a dsh restart continues it from dsh's own log instead. Type /temporary again to switch back. The mark lives in memory (it survives a plugin reload, not a dsh restart).
Process. One claude process stays alive per dsh session (processIdleMs, default 30 min; maxProcesses, default 4, evicts the longest idle). Turns after the first start in about a second because hooks, CLAUDE.md and MCP servers are already loaded. A change of model, effort, working directory or permission mode replaces the process; the Claude session is resumed, so nothing is lost.
Approvals and questions. With approvals: true (default) the child runs with --permission-prompt-tool stdio. When Claude Code would ask permission, dsh's own approval dialog appears; Approve runs the tool, Deny tells Claude the user refused. Claude's AskUserQuestion becomes a dsh question form and the answer goes back to Claude. Under Full Access nothing asks, except plan review: when Claude leaves plan mode (ExitPlanMode arrives as a permission request carrying the plan), dsh's own Plan review panel shows it with Approve and Keep planning; approval lets the tool run, anything else goes back to Claude as "the user chose to keep planning" with the typed feedback. Each ask also shows as a ⚑ approval: Tool … or ❓ question … row.
Secrets. Values of environment variables whose name looks like a secret are masked in Claude's tool results before they reach the session log (redactSecrets), since the CLI inherits dsh's environment and a cat .env would otherwise persist verbatim. Only the values this process can see are known; secrets read from files are not.
Restarts. With spawn: keeper (default) a dsh restart does not touch Claude: each claude process is owned by a keeper (lib/server/keeper.js, one per session under ~/.local/state/dsh-oh-my-claude/keepers/<id>/, launched in its own systemd user scope so the service's cgroup kill misses it). The keeper owns Claude's pipes, buffers its output while no dsh is attached (bounded, oldest lines dropped with a notice), and dsh talks to it over a unix socket. On boot the plugin reattaches to every keeper whose Claude is still alive, and if output was waiting it opens a turn that shows it, with a "reattached" notice as the next prompt. The MCP bridge key lives in mcp.key in the state dir so a surviving Claude still reaches the new dsh's tools; Claude's MCP client may still need to reconnect once after the restart. Kill semantics are unchanged: Stop, eviction and the idle timers end the keeper's Claude.
With spawn: node or dsh, a dsh restart kills every Claude Code child. Sessions that had a turn running are written to ~/.local/state/dsh-oh-my-claude/busy.json as the turn starts and removed as it ends; about ten seconds after dsh comes back, each one still listed gets a plugin notice as a real prompt ("dsh restarted while this turn was in progress…"), the Claude session resumes with --resume, and the work continues without anyone typing. Sessions that were idle are left alone. Hot reloads keep their processes and are not restarts. Two guards keep this path from doing harm: a boot within a minute of the previous one is treated as a crash loop and nudges nothing (the busy list is left for a later healthy boot), and a session whose durable inbox already holds an unconsumed restart notice is not nudged again. The trace of every boot is resume.log next to busy.json. The nudge starts one turn; for work that should keep going across restarts with nobody at the keyboard, put it under a dsh goal (/goal or create_goal): dsh's goal round driver starts the next round whenever the agent is idle with an armed goal, and since dsh disarms goals on session resume, the restart notice tells the model to rearm it with update_goal (action resume).
Streaming. Text and thinking arrive as live deltas. Claude Code's own tool calls render inline in the stream by default; with Tune's Tool activity switch on Native rows (only where the running dsh loads them, see Tune) the adapter appends tool/call and tool/result session events inside the open step, the same shape dsh's loop writes for its own tools, so Bash, Read, Edit, Write, Grep, Glob, WebFetch and WebSearch each get dsh's presenter for that tool, MultiEdit shares Edit's, and every other Claude tool keeps its own name on the generic row. Bash shows its description; an edit shows the +/- badge from meta.diffs. Inline headers take dsh's own row typography too: the tool name, a 2 px dot and the summary in the tertiary colour, split out of the translator's name · summary line by the same client pass that lifts the glyph. dsh never runs those tools; Claude Code does, under the configured permission mode. Fable-class models return thinking blocks with an empty body, so no reasoning text appears for them; local and Sonnet-class models stream theirs.
Turn status. While a turn runs on a session this plugin drives, the status row under the last message takes Claude Code's look instead of dsh's blue Deep diving...: a spinner glyph played through Claude's own frames, a verb picked per turn from the CLI's list (settings.json spinnerVerbs is honoured, append or replace), Claude orange, the elapsed clock kept. After the verb sits a bracket the way the CLI's own line writes it: the clock, the turn's running token count (summed across every message of the turn plus the estimate for a thinking block in progress, eased toward its target the way the CLI eases its own), and thinking while a thinking block is open. The word climbs the CLI's ladder (still thinking at 10s, thinking more at 20s, thinking some more at 30s, almost done thinking at 45s), names the effort when dsh asked for one, and gives way to thought for Ns for two seconds once the block closes. Colours follow the CLI's spinner: a thinking burst past ten seconds warms the glyph, verb and word toward the theme's warning shade over the next ten, a response that goes quiet for ten seconds tints them toward the CLI's stall red, and the shimmer sweeps only while neither is up. The figures come from the plugin's live-turn route, polled once a second while the row is up and not at all in a hidden tab; everything else is read from the CLI bundle and recorded in the owner's notes. The row has no slot, so the client restyles it through a DOM watcher (watchTurnStatus), only when the session's provider is claude-code. The word ultrathink, typed in the composer, waiting in the queue or sent in a message, takes the CLI's rainbow (one colour per letter, red through violet, wrapping), with the CLI's shimmer sweeping over it in the composer; ultracode takes the CLI's purple in the composer only, under the CLI's own matcher (a quoted or slash-command occurrence is left plain). Both go through the CSS Highlight API, so neither the composer nor the chat sees a DOM change. The same Claude orange tints the running dot beside each session in the sidebar under Workspaces, but only for Claude sessions: a second watcher (watchSessionSpinners) colours the matrix dot for sessions whose provider is claude-code, matched by their title in the row, and leaves any other provider's dot dsh's default. A session that changes to another provider mid-flight loses the tint.
Compaction. The CLI announces compaction with a compacting frame, goes silent while it summarises, then emits the boundary; both ends show in the reasoning lane, and a failed compaction is reported.
Task progress. The CLI's subagent runner emits task_started when a task begins, task_progress frames as the task runs (carrying last_tool_name, usage token counts, summary), and task_notification when the task finishes or fails. The plugin keeps one reasoning block open per running task so dsh renders each as a single collapsible row with the start line, progress updates as they arrive (tool name and token counts), and the final status. Identical progress frames append nothing so a chatty task does not fill its row with repeats. A task without a task_id renders as a single closed line. background_tasks_changed is silent (it is list churn). Source: src/translator.ts and tested in src/adapter.test.ts.
Todo panel. dsh clears its todo projection at every turn start; the adapter re-appends the last todo/write inside the open turn (persistTodos), so the panel keeps the list across messages and restarts.
Images and files. Image attachments in the user turn are read from dsh's attachment store and sent inline as base64, and a copy is kept under ~/.local/state/dsh-oh-my-claude/attachments/<hash>.<ext> with the extension its media type calls for, since dsh's own stored object is named by hash with none and Claude Code's Read decides image-or-text by the name. A note after the prompt names that path, the display name and the size, so Claude can Read the image again, edit a copy, or hand the path to a subagent instead of only seeing the pixels. A file attachment needs nothing from the plugin: dsh-llm replaces every file block with a line naming the file and the read-only path it is stored under before any provider sees the turn, and Claude reads that path with its own Read tool.
Session browser. Settings → Oh My Claude (its nav row carries the spark in the row's own text colour: dsh picks nav glyphs by section id and gives every other id its gear, so a registrant in the settings.action list slot, which mounts with the dialog, swaps the gear for the spark) opens with a runtime line (which claude, which account, which box) and one list of every Claude Code transcript in reach: all workspaces of this box (~/.claude/projects/*) plus each reachable saved box, fetched box-side over the same login the probe uses. Chips filter by box (an unreachable box shows as offline and stays disabled), selects filter by workspace and origin, and a search box narrows to rows whose title, id or path contain every typed word, in any order (matchesQuery, checked in pageSessions.test.ts); rows are grouped by box, newest first, each tagged dsh, archived or terminal with its workspace. On this box, Open opens the dsh session, Restore unarchives it first, and a terminal transcript is imported: converted to dsh events (prompts, replies, thinking, tool calls and results) so the history renders, with the dsh session taking the Claude session id as its own id, so the next prompt resumes that very Claude session with its full context. The transcript is only read; Claude Code keeps appending to the same file, so the session can be continued from either side. Claude's own subagent sidechains and an unanswered trailing prompt are left out of the copy. A row from another box says "Open on " and sends the browser there with #claude-session=<id>&cwd=<path>; that box's panel picks the link up once dsh is ready and opens the session the same way. Below the list, Boxes and settings.json are collapsed cards with a one-line summary each. The browser half is src/client/index.tsx, built into lib/client.js by bun run build.
One control beside the composer. Every plugin feature that acts on the open session lives behind one button in the composer's left group (slot conversation.input.left): the Claude mark ✻ in Claude orange in the same 28 px round as dsh's + and attachment buttons, bare at rest and filled with dsh's hover token under the pointer or while the panel is open, labelled "Oh My Claude" for screen readers and, on hover or focus, in the same bubble dsh draws over its own composer buttons (Tooltip from dsh-client-ui-primitives, hidden while the panel is open). It opens a single panel of tabs: Memory with its file count, Instructions, Rewind, Changes, MCP, Asides, Diagnostics, Tasks and Tune, led by Restore on a blank session; the last tab used is remembered. The panel opens over the composer the way dsh's own slash menu does: portalled into the composer card (data-composer-card, the hook that menu dismisses by), the card's full width, 4 px above it, on dsh's menu surface and 20 px corners, its height clamped to the viewport by dsh's useAnchoredMaxHeight and eased between tabs (a ResizeObserver plus one Web Animations run per settled height, off under reduced motion). On a host without that card it falls back to a fixed float above the control, and under 640 px to a sheet, so a phone never scrolls the composer row sideways. The tabs are described below as they behave.
Restore from a blank session. A new dsh session gets a Restore tab. When its workspace has Claude Code transcripts not already open in dsh, the composer's spark carries an orange dot until the panel is opened there, the panel opens on Restore, and the very first time on a box the disc pulses three times (remembered in the plugin's state as a hint, so it never repeats after a browser change or an update; off under reduced motion). With nothing to restore the tab says so and names the folder. It lists that workspace's transcripts not already owned by a live dsh session, newest first, eight at most, in a popover; picking one runs the same open-or-import path as the session browser. Past eight, a search box above the rows narrows them the same way the session browser's does. The tab disappears once the session has content.
Memory. Claude Code's auto-memory lives under <project dir>/memory/ as one Markdown file per fact with MEMORY.md as the index it loads each session. The Memory tab shows the count in its label and lists the files (index first, then newest first, with each file's frontmatter description and age); picking one opens an editor with Save (Ctrl or Cmd plus Enter) and Delete. Deleting a file also drops its line from MEMORY.md. When Claude saves or recalls memories during a turn (the CLI's memory_saved and memory_recall frames) one reasoning line says so, and the button's count follows within half a minute. The routes (GET, PUT, DELETE /dsh-oh-my-claude/memory) resolve the directory through the instance's configDir, so each mounted instance sees its own memories.
Instructions. The Instructions tab lists the CLAUDE.md files the session loads, in the order the CLI loads them: the managed file under /etc/claude-code, the user's under ~/.claude, then each ancestor of the workspace from the filesystem root down, ending at the workspace itself, with CLAUDE.local.md after each. A file pulled in by an @ line follows the file that imported it, keeps its scope and names its importer; imports nest five deep and a cycle is walked once. @ is read the way the CLI reads it, so an address, a code span or a fenced block is not an import. Each row shows the scope, the path shortened against the workspace or home, the size and when it changed; picking one opens the Memory tab's editor. The managed file opens read-only. Routes: GET /dsh-oh-my-claude/instructions?cwd= for the list, GET/PUT /instructions/file for one file. The list is recomputed per request and is the allowlist, so a path it does not name is refused, and the cwd is checked against the directories dsh has a session in. Below the file list, a roster shows the plugins and marketplaces the session's settings turn on, read the way the CLI resolves them: enabledPlugins per key with the highest scope winning, and extraKnownMarketplaces with its additionalMarketplaces alias folded in, each row naming the scope its value came from. It is src/plugins.ts, served at GET /dsh-oh-my-claude/plugins?cwd=. The roster acts on that list, not just reads it: each plugin row has an enable/disable toggle and an Uninstall, and a marketplace add form takes a URL, path or GitHub repo with a Remove per marketplace. Each control shells out to the CLI (claude plugin enable/disable -s <scope>, uninstall, marketplace add/remove) in the session's working directory, the same way the MCP tab runs claude mcp, and re-reads the roster after; enable, disable and uninstall write back to the scope the value came from, and the change takes effect at the next spawn, said on the row. Routes: POST /dsh-oh-my-claude/plugins/toggle, /plugins/uninstall, /plugins/marketplace/add, /plugins/marketplace/remove, each validating scope, id and source before it runs. Under the plugin roster, a Skills list names every skill the CLI can reach from this directory, with where it comes from: user (~/.claude/skills), project (the workspace's .claude/skills) or plugin:<name> (an installed plugin's skills/), each with the first line of its SKILL.md description, and a search once there are more than a dozen. Read-only: a skill runs as its slash command through the command bridge and is edited where it lives. On an ssh box the list carries names only, one round trip per directory, since reading a hundred SKILL.md heads over ssh would be a hundred.
Permission mode per session. dsh's access shield by the composer is the control, in a Claude session with Claude's rows: Plan (read-only), Ask and Accept edits (workspace write), Auto, Don't ask and Bypass (full access). A pick first switches dsh's preset through its own /permission command when the mode needs another one, then stores the Claude override per session under the instance state dir (permission-modes.json), used for --permission-mode at the next spawn or resume and pushed to a live process with the CLI's set_permission_mode control request. The override can only be as loose as dsh's preset maps to (plan, acceptEdits, bypassPermissions); the server refuses a looser one. dsh's trigger and menu are kept and relabelled, so other providers see dsh's shield unchanged. Routes: GET/PUT /dsh-oh-my-claude/permission-mode.
Rewind. The Rewind tab lists the session's user prompts from Claude's transcript. Picking one dry-runs the CLI's rewind_files control request and shows how many files would go back with the insertion and deletion counts; confirming runs the real file rewind and then rewind_conversation, so Claude's context ends at that prompt. dsh's own transcript is left as it is, so the conversation view still shows what happened. Needs the session's Claude process alive (it is, after any prompt in this dsh session), and control responses are read as they arrive, so this and the live permission switch work between turns too. Stream-json runs leave file checkpointing off, so every Claude child is started with CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING=1; prompts from before this version have no checkpoint and answer "No file checkpoint found". Routes: GET /dsh-oh-my-claude/rewind?session=&cwd= and POST /rewind.
Tune. The Tune tab's first row is the plugin's own, not a settings.json key: Tool activity, Inline or Native rows. It is saved under the plugin's state directory and read at the start of every turn, so a flip takes effect on the next message with no restart. Native rows are locked, with dsh's own refusal in the hint, on a dsh that will not load a session holding them. Claude runs its tools inside one dsh step, before the settled assistant/message exists, so a row is a tool/call that message never advertised; dsh 0.1.5's format migration refuses those, which is what broke rows-mode logs at the v0 to v3 bump, while its plain load of a current-format log does not check them. The plugin settles which at startup by feeding a synthetic v3 log through dsh's session-format catalog with the options dsh's own load passes, so the switch follows the running dsh with nothing to update. With rows on, the answer bubble moves below the tool cards when the turn ends, and the next format migration may refuse the rows again; tools/dsh-session-repair.ts mends such logs. The rest of the tab holds the settings.json keys that change how Claude answers, on the panel that already shows the answer: output style (Default, Explanatory or Learning), thinking (alwaysThinkingEnabled, with showThinkingSummaries beside it and disabled while thinking is off) and the auto-compact window in tokens. Each row says where its value comes from, settings.json or Claude Code default, and a control returned to its default deletes the key rather than writing a false. The auto-compact field commits on blur or Enter, never per keystroke, because every write moves the file's mtime. Two more rows set the prompt cache TTL, promptCacheTtl for the main conversation and subagentPromptCacheTtl for subagents and background work, each offering Default, 5 minutes or 1 hour, with the trade-off under them: an hour keeps the cache warm across longer breaks and its writes cost more. CLAUDE_CODE_PROMPT_CACHE_TTL in the environment beats whatever the tab writes. The Advisor row sets advisorModel: Off (key absent, the CLI's default) or a model id from the plugin's catalog. Fable models bill to usage credits and must be enabled from the terminal (/model fable) before the tab offers them. An advisor weaker than the main model is not used for the main conversation, though subagents may still use it. A Fallback model row sets fallbackModel, Off or a model from the catalog, the one the CLI switches to when the main model is overloaded. Two deadline rows set how long the CLI waits on a person: Question deadline (askUserQuestionTimeout: Default, which never expires, 1, 5 or 10 minutes, or Never) for a question Claude asks, and Approval deadline (dialogExpiry: Default of 5 minutes, or the same choices) for a parked permission prompt. Bash output and Task output size what Claude receives back from a shell command and from a subagent (bashOutputMaxChars, taskOutputMaxChars, 4,000 to 128,000 characters, committed on blur or Enter like the auto-compact field). An Attribution block holds the CLI's commit and PR trailers: a No AI trailers toggle writes both attribution.commit and attribution.pr empty, and with it off each has a text row for a trailer of your own; the default leaves the CLI's. Writes go through the same PUT /dsh-oh-my-claude/settings route as the editor, with its .bak and atomic rename, and re-read the file first: an edit that landed since the tab was opened is shown rather than overwritten. Every row takes effect the next time Claude spawns, which the tab says. A Permissions block closes the tab with the three rule lists the CLI answers tool requests from, allow, deny and ask: a row per rule with a Remove button, an add form, and a chip per tool call this session stopped to ask about, already written as the rule that would have answered it (Bash(npm run:*) for npm run build, the path itself for anything carrying one). Clicking a chip fills the box, so a rule that is too broad is edited before it is saved, and a chip disappears once its rule is in a list. A rule that is not a tool name with an optional specifier is refused here rather than written and ignored at spawn. The suggestions live in memory for the life of the process, ten per session, and are served by GET /dsh-oh-my-claude/permission-asks?session=.
Changes. The Changes tab asks the CLI for its own working-tree diff (get_workspace_diff), so the tab shows the tree Claude is looking at: one row per file with its added and removed counts, and a row unfolds into its hunks. A binary or untracked file says which it is instead of showing hunks it has none of. Route: GET /dsh-oh-my-claude/diff?session=.
MCP. The MCP tab lists the servers Claude's own process has, each with its connection status, the bare names of the tools it contributes, a Reconnect button and a Remove button. The names come from the CLI's init frame, which lists every tool the session holds as mcp__<server>__<tool>; mcp_status, which supplies the rows and their status, does not report tools. A server that is down carries its own error under its row, and one that needs authentication says so instead of offering a Reconnect that cannot help it. Under the list, an Add form takes a name, a scope (user, local or project), a transport (stdio, SSE or HTTP) and that transport's fields: a command with one argument per line and KEY=value env lines, or a URL with Name: value header lines. The form is parsed into the JSON claude mcp add-json takes, and both it and claude mcp remove run in the directory dsh has on record for the session, so a local or project server lands in the project on screen. For a remote server the form carries a preset dropdown of common connectors (GitHub, Notion, Slack, Linear, Sentry, Google Drive) that pre-fills the name, URL and transport; the list is a static const in the client, so it adds no request, and OAuth is still finished in the terminal as the needs-auth row instructs. Adding or removing takes effect the next time Claude spawns, not in the live session, which the tab says. Beside the process's servers the tab lists the ones the CLI is configured with for this directory that are not running yet, read from .claude.json (user and local scope) and the workspace's .mcp.json (project scope) rather than from claude mcp list, which connects to every server; each row carries its scope, a server added a moment ago shows as "starts with the next session" with its own Remove, and a live row learns its scope from the same list.
Asides. /btw <question> asks Claude something without spending the transcript on it: the line goes over the CLI's side_question control request and the answer opens in a bubble docked above the composer instead of joining the conversation. The command returns before Claude answers, so the bubble shows the question with a spinner first. The Asides tab holds the last ten of a session as an accordion with the newest open, which is where an answer is re-read after its bubble was closed; the ring is written to the plugin's state dir, so a restart, a hot reload or an eviction does not lose one. Routes: GET /dsh-oh-my-claude/side-questions?session= and POST /side-questions/dismiss.
Prompt starter. A session with no messages yet shows a small card above the composer offering an opening prompt: the one saved for that session, or, on a brand-new tab, the last one saved anywhere. Clicking it fills the composer without sending, so it can be edited first. "Save draft" stores whatever is in the box as that session's opener and as the default the next new session is offered; it reads "Saved" and is greyed while the composer already matches the saved opener. "Forget" clears it after a second click. The card is one row whatever the draft's length: the opener and the hint shrink with an ellipsis, the buttons keep their place, and the row keeps its height, so nothing under it moves. Each button slides in and out over 200 ms (none under reduced motion), reserves the width of its widest label, and the Forget button holds its width while it reads "Sure?". A switch at the top of Settings → Oh My Claude turns the card off for the whole box; it is on by default and the flag lives as starterOff in hints.json in the plugin's state dir (GET/POST /dsh-oh-my-claude/hints, a false dropping the key). It hides as soon as the session has a message or the composer holds text of its own. Openers live in starters.json in the plugin's state dir; the routes are GET /dsh-oh-my-claude/starter?session= and POST of {session, text}. Filling the composer uses inputActions.setDraft, which dsh hands to every entry of the composer's dock slot.
Session notices. When a Claude session finishes a turn while you are looking at another tab, a bullet is added to the browser tab's title so the waiting session is visible from anywhere. A desktop notification is offered on top of that, but only after you opt in from the Diagnostics panel's own toggle: the browser's permission prompt is asked for from that click and never on page load, since an unprompted request is what makes people deny it for good. With permission granted the notice names the session and clears itself once you look at it; without it, or where the browser has no Notification API, the title bullet carries the signal alone. The transition logic is src/client/notices.ts and the watcher is watchSessionNotices in the client; both track the session list, never a single session's stream, so the plugin needs no extra slot.
Diagnostics. The Diagnostics tab shows the runtime status (which claude binary, its version, login state, config directory), the settings files the CLI reads with their parse errors if any, and a button to run claude doctor for detailed diagnostics. Routes: GET /dsh-oh-my-claude/diagnostics?cwd= for the summary, POST /dsh-oh-my-claude/diagnostics/doctor for the doctor output. The panel also tracks permission denials from the result frame in the turn records: which tool calls a permission rule refused, by rule label and turn number, so the user knows whether a tool was silently skipped by policy.
Tasks. The Tasks tab (read-only in this release) shows scheduled tasks and the CLI's active goal: durable tasks persisted to <project dir>/.claude/scheduled_tasks.json (missing file = empty list), session-only tasks reconstructed from the transcript by scanning for CronCreate and CronDelete tool calls (a later delete cancels a create, and only non-durable tasks appear), and the most recent ProposeGoal-family tool call from the transcript with its timestamp. Route: GET /dsh-oh-my-claude/scheduled-tasks?session=<id> returns { ok, durable, session, goal, path } or { ok: false, error }. Session-only tasks are best-effort reconstruction because the CLI stores them in memory only; this tab cannot list a session that is not active in the current dsh session.
Idle watchdog. idleTimeoutMs still stops a process that produces nothing for that long, but no longer silently: half a timeout before the stop (60 s when the timeout is two minutes or more) a reasoning row announces the countdown, and the session header shows stopping in Ns with an Extend button that pushes the deadline out by a full timeout. A tool call in flight pauses the watchdog, as before. GET /dsh-oh-my-claude/idle?session= and POST /idle/extend are the routes behind the chip.
settings.json editor. The same Settings → Oh My Claude panel shows Claude Code's own settings.json (the instance's configDir, else $CLAUDE_CONFIG_DIR or ~/.claude): a summary line (model, hooks, permissions, env, plugins) and the file itself, read-only until Edit is pressed; then a plain editor with live JSON validation, Cancel and Save (Ctrl/Cmd+S), so browsing the panel can never change the file by accident. Save keeps the previous copy as settings.json.bak and writes through a temp file, so a crash mid-write never leaves a half file. Only a JSON object is accepted. A Scope control picks which of the four files the CLI merges you are editing: user, the project's .claude/settings.json, its settings.local.json, or the managed file at /etc/claude-code/managed-settings.json, which is shown but never written. Project and local need a directory, so a second select offers the directories dsh has a session in, and the two scopes stay disabled until there is one. The server resolves the path itself from the scope and that directory, and refuses a directory no session reports, so the browser can never name a path to write .claude/settings.json into. Under the control, a muted line names the keys a higher-precedence file overrides (Overridden here: model by the managed file; env by settings.local.json.), which is what makes an edit that appears to do nothing legible. Hooks and permissions edited here apply to every Claude Code process that reads that file, in dsh or in a terminal; dsh's own hooks live elsewhere and are untouched. Routes: GET/PUT /dsh-oh-my-claude/settings, the PUT taking an optional scope and cwd and defaulting to user, and GET /dsh-oh-my-claude/settings/scopes?cwd= for every readable scope at once, behind dsh's login like the rest. Each saved box row has an Edit settings toggle that opens the same editor on that box's file, user scope only, proxied through this box's server over the probe's login (GET/PUT /dsh-oh-my-claude/boxes/settings?url=<box>); the box token never reaches the browser.
Plan usage. Opening dsh's context-used ring (the meter beside the send button) shows the Claude plan windows above the context line: 5-hour, weekly, and any per-model weekly limit, each with its used percentage and reset time. The data is the OAuth usage endpoint behind Claude Code's /usage, read with the stored login (an API key has no plan usage), cached five minutes server-side and thirty seconds on a forced refresh. The ring's hover tooltip gets one compact line (Claude 5-hour 31% · weekly 15% (box)); the panel title names the login and box the usage belongs to, since the browser hops between boxes with their own accounts. An Extra usage row closes the list with the state of usage credits (Off, On with what they have spent against their cap, or Limit reached), the payload's own sentence about what credits cover, and a purchase link only when the endpoint says this account may buy them. Any new limit kind Anthropic adds shows under its API name. The meter has no extension slot, so the client watches for its dialog and tooltip and adds to them; source src/usage.ts and watchContextMeter in the client.
Content search. dsh's sidebar search can search message content through its own FTS5 backend (dsh-session-query-sqlite), which dsh ships switched off (openAt: never). Turning it on is a dsh deployment setting in the profile's cordis.patch.yml, not something this plugin does or needs.
Turn accounting. On every turn the adapter extracts total_cost_usd, duration_ms, duration_api_ms, num_turns and the token counts from Claude Code's result frame and stores them in a per-session ring buffer (last 50 turns) on the adapter instance. A GET /dsh-oh-my-claude/turns?session=<dsh session id> route returns the list plus a session total; empty for an unknown session, 400 without session. The client fetches the route on mount and every 10 seconds while the tab is visible, renders nothing when the list is empty, and appends $18.21 · $0.42 last · 643K cached to dsh's footer stats row as a third pill after dsh's own two. The pill is built the way dsh builds its own (StatsPills, ui-chat): a button carrying the row's own pill classes, so it takes dsh's hover and pressed styling in any build, led by Claude's spark in the pill's text colour where dsh's pills carry their icon, aria-haspopup="dialog", and a click or a tap opens a dialog above it, data-omc-cost-dialog, placed by dsh's useAnchoredPosition and closed by an outside pointer, Escape, or the cost leaving the row. The dialog lists the last turn's cost, the turn count, wall and API time, input, cache read, cache write and output tokens, the cache hit share, and the last turn's time to first token; a phone, which has no hover, gets the same detail from the tap. costDetails builds those rows and is tested in src/client/stats.test.ts; tools/playwright/cost-pill.ts drives the pill on a desktop and a phone viewport. Format helpers fmtCost, fmtDuration, cacheShare are exported and tested in src/client/turns.test.ts. Time to first token is wall-clock from the prompt write to the turn's first stream chunk, measured in the adapter (firstChunkAt - promptSentAt, the result frame carries no first-token latency on 2.1.263) and stored as ttftMs on each turn record, so it survives a restart with the rest of the ring.
dsh tools over MCP. Every dsh tool the session's agent can see, except the shell and file ones Claude Code has natively, is served to the Claude process as an MCP server named dsh (--mcp-config, Streamable HTTP on the dsh web port, path /dsh-oh-my-claude/mcp/<session id>, guarded by a key generated per dsh process). Claude Code sees them as mcp__dsh__*: subagent_local, researcher_local, list_agents, send_message, jobs, goals, skills, web search, and bash (for long-running commands that you would run with run_in_background: dsh registers the job, shows its card and panel entry, and delivers the finish notice next turn; short foreground commands stay on native Bash). A subagent started this way is a real child of the dsh session: it runs on whatever route the preset pins, shows in the session header's subagent dropdown, and its completion notice reaches the next Claude turn as context. Calls to those tools are relayed, not executed in the bridge: the adapter ends the current step with a real dsh tool-call, dsh runs the tool in its own loop (so a subagent shows the native card, the header count, and its completion notice like any dsh-native session), and Claude's MCP request is answered with dsh's result when dsh sends it back. If no live turn can take a call (idle process, a relay already pending) the bridge executes it directly as before. Steers are forwarded to Claude's stdin the moment dsh receives them, so the CLI injects them at its own next tool call instead of waiting for the turn to end; the same message is skipped when dsh later delivers it at a boundary (matched by the prompt's rpcId). When no tool call follows, the CLI answers the steer as a turn of its own, and the dsh turn that re-delivers the steer shows that answer. Parallel dsh calls in one Claude step are gathered into one dsh step so dsh runs them in parallel. Stop in dsh sends the CLI an interrupt and keeps the process for the next turn instead of killing it. Forking a Claude session in dsh copies the parent's Claude transcript under the new id, cut at the forked turn, so the fork keeps Claude's context. One tool is the bridge's own, not dsh's: open_session starts a new top-level dsh session in the caller's workspace (optional provider, model, agentPreset, workspaceId), sends it a first prompt, and returns the session id. It shows in the sidebar as its own row and nothing comes back to the caller. While the bridge is on, the appended system prompt also tells Claude to route every subagent through mcp__dsh__* and never through its own Agent tool, whose children dsh cannot see; it points at mcp__dsh__list_subagent_models for the allowed routes rather than naming any. Switch off with dshTools: false. Source src/mcp.ts.
Command bridge. The CLI's init frame lists its slash commands (skills, custom commands, built-ins such as /compact). Each name dsh's command grammar accepts is registered as a dsh /claude-<name> on first sight (default instance only), so the composer's slash menu offers /claude-verify, /claude-context and the rest under a description naming the Claude command. The prefix is deliberate: dsh's command menu refuses a host command whose name matches a client-side contribution, and Claude's catalog is long. Running one hands /name <arguments> to Claude as the next prompt, where the CLI expands it exactly as the terminal would; the composer shows /name sent to Claude Code. Each bridged command declares input.attachments, so a file or image in the composer goes with it (dsh refuses attachments to a command that does not declare them, with /name does not accept attachments), and they reach Claude the way a typed turn's do. A bridged line with attachments is logged as a user turn rather than the collapsed notice row, so the bubble shows the file or image chip a typed prompt would. Renaming through the bridge sets dsh's title as well: /claude-rename <title> renames the dsh session first and sends the line only if that worked, so the two names cannot end up disagreeing, and dsh's title is pinned afterwards so automatic title generation stops replacing it. Switch off with commandBridge: false.
Auxiliary calls. dsh's session-title and compaction requests run as one turn with no tools and no session of their own, from a scratch directory so they never show up in a workspace's session list.
Errors. Abort from the UI kills the child. Non-zero exits surface with the last stderr; a real usage limit surfaces as RATE_LIMIT with the provider's reset time as retry-after, the CLI answers a rejected request with its own synthetic message ("You've reached your Fable limit. Switch to another model, or manage usage credits at …") and that text is relayed whole, whatever the cause it names. The failure row adds only what the event's own fields say: which window (rateLimitType), when it reopens, printed as the CLI's error reference does (1pm later today, Tue 1pm inside the week, Sep 8, 1pm beyond it) in the browser's zone, and "· continuing automatically when it resets" when the wait is on. While the CLI retries a 429 or 5xx by itself, each attempt is one reasoning line with the wait, the reset clock (in the browser's zone dsh stamps on each prompt, else the box's) and the attempt count, so the turn never looks busy for nothing. With continueAfterLimit on, the plugin arms a timer for the reset (kept in limit-waits.json, so a restart re-arms it) and then drops a continue notice through the same path a restart uses; any prompt sent before then cancels the wait. Before the notice goes out the plugin checks that the session still runs on this provider (a reroute to another model drops it) and asks the usage endpoint whether a window is still at its cap (then the wait is re-armed for that reset). A rejected request that paid extra usage covers is not a limit at all, the turn goes on as in the CLI; extra usage turned on during a wait needs no detection, since any prompt cancels the wait and Claude's next limit event says whether credits cover the overflow.
Surviving Claude Code updates. Claude Code updates itself. On first use per process the adapter reads claude --help and --version; any flag the installed CLI does not list is left out (--effort, --append-system-prompt, --include-partial-messages, --max-budget-usd, session flags). Without --input-format the prompt goes positionally and images are skipped. Whole-message fallback covers a CLI that stops sending partial events. Started session ids are kept in ~/.local/state/dsh-oh-my-claude/sessions.json; a --resume the CLI rejects is retried once as a fresh run. Model ids and effort levels come from the Models API, so new models need no code change. The version in use is logged at first request.
Where things live
The plugin runs Claude Code as a child process of dsh, so everything is on the machine that runs dsh web:
- Binary:
claudefrom that process'sPATH. No path setting; put it on the PATH of the user running dsh. - Config dir: the plugin's
configDirif set, else$CLAUDE_CONFIG_DIRif set for the dsh process, otherwise~/.claudeof that user. Transcripts (projects/),settings.jsonand the login token all live there, the same place a terminalclaudeon that machine uses. - Login: done once, in a terminal on that machine, with
claude auth login. The panel's first line shows which binary, which config dir, which host and which account dsh sees; if it says not logged in, that is the fix. The model picker says so too: a mount whose claude has no login is listed as<name> (not logged in), from oneclaude auth statusat mount and from every probe the panel runs after that. The models stay listed, so a session already on that box can still show the error a turn produces. The This box row in Settings → Oh My Claude → Boxes offers the same Log in the ssh rows have when the CLI is there but logged out: it runsclaude setup-tokenunder a local PTY (script), hands you the sign-in link, takes the pasted code and stores the minted token under the plugin's state (ssh-tokens/.this-box); the default instance hands it to every local Claude it starts asCLAUDE_CODE_OAUTH_TOKEN, the status pill reads "panel token", and Log out forgets the token. A second instance keeps its own login. With noclaudeon PATH the row says so instead; the plugin does not install it.
Several accounts. Mount the plugin more than once in the profile's cordis.patch.yml, with the same name: dsh-oh-my-claude, distinct id values (the bundle's own row is oh-my-claude), each with its own configDir and a providerId starting with claude-code-. The extra row goes under insert; a bare - id: entry only overrides a row that already exists and an unknown id is silently dropped:
- id: oh-my-claude
config:
configDir: ~/.claude
- insert:
- id: oh-my-claude-work
name: dsh-oh-my-claude
config:
providerId: claude-code-work
providerName: Work
configDir: ~/.claude-work
Each mount gets its own CLAUDE_CONFIG_DIR, state files under ~/.local/state/dsh-oh-my-claude/<providerId>/ (sessions, busy log, resume trace), and a separate row in the process registry, so the two logins never mix. In v1 the session browser, settings editor, MCP bridge, usage route and turn-status panel all belong to the default claude-code instance; a non-default mount logs one info line saying so.
Same box, several clients (laptop, phone, another PC on the LAN): run dsh web where Claude Code is logged in and open that URL from anywhere.
Several boxes. For the Boxes list the plugin does not ssh: a wrapper named claude that did would run the model elsewhere while the panel still read local transcripts and settings. Instead, install dsh and this plugin on each box that has Claude Code, and list the others under Settings → Oh My Claude → Boxes (name, URL, optional dsh token). (To drive one remote claude directly, with no dsh on the far side, see "A box over SSH"; that is a different trade, and its panels reach less far.) Each row is probed from this dsh: host, claude version, who is logged in, plugin version (a mismatch is flagged). Open jumps the browser to that box; sessions and logins stay where they are. The token is that box's dsh launch token, needed only when this browser has never logged into it; a proxy that injects the token needs none. Saved in ~/.local/state/dsh-oh-my-claude/boxes.json, routes GET/PUT /dsh-oh-my-claude/boxes and GET /dsh-oh-my-claude/boxes/status, behind dsh's login.
Remote through dsh's own seam. dsh separates what runs a process from who asks: ctx.subprocess is a seam, and a community provider can mount a remote one. With spawn: dsh this plugin starts claude through that seam instead of node's spawn, so a workspace that such a provider routes to another machine runs Claude Code there, with that machine's login and transcripts, and no ssh code in this plugin. Caveats: the seam's environment is dsh's scrubbed one (credentials come from the remote login); the MCP bridge URL points at this dsh's port, which a remote process cannot reach unless forwarded, so dshTools is best off for such workspaces; the session browser and settings editor stay local. The seam contract is covered by src/adapter.test.ts; a live remote run needs such a provider mounted.
A box over SSH. When there is no dsh on the far box and no remote subprocess provider, sshHost drives its claude from here directly. With spawn: keeper (the default) the far claude is held in a session of its own on the box, its stdin a FIFO and its stdout a file under ~/.local/state/dsh-oh-my-claude/hold/ there, so a dsh restart here reattaches from the byte the last one read and a dropped ssh reconnects without the session noticing. Nothing runs on the box but the CLI. A box on a tailnet or behind WireGuard is the same box: sshHost takes the MagicDNS name, the Tailscale IP or the WireGuard address as it is, and the Boxes tab has Tailscale and WireGuard as box kinds: Tailscale shows whether this PC is on a tailnet and joins it from a Connect button: with Tailscale's own service the approval link opens in your browser; with a self-hosted Headscale you type its URL as the login server and, if you made one with headscale preauthkeys create, a pre-auth key, and it joins with no browser at all (the same tailscale up --login-server … --authkey … Headscale documents). Peers are then a pick; WireGuard lists the peers of any tunnel that is up, with its last handshake, and the tunnel address is the host. When a box does not answer, the row says which of four things it is (name not found, unreachable, host key, key refused) and what to do about each, plus "no claude" for a box that answers but has nothing to run. The Boxes tab does this for you: give a box a name and user@host (or an ~/.ssh/config alias) and the plugin mounts a claude-code-<name> instance from its own ssh-boxes.json, at boot and on each change, so no config edit and no restart is needed. By hand, mount an instance the way "Several accounts" does, give it a providerId starting claude-code-, and set sshHost to the host ([user@]host or an ~/.ssh/config alias): Adding a workspace on a box goes through dsh's own sidebar "+": while at least one SSH box is saved, that button opens this plugin's copy of dsh's Select Workspace Directory dialog (src/client/browser.tsx, ported from dsh 0.1.5 because dsh does not export it: same header with the breadcrumb and the pencil that turns it into a typed path, the same two-pane Miller view, nested New folder, truncated and loading notices, footer bar and copy, on dsw tokens) with one addition: a box dropdown first in the footer bar, dsh's Menu on a selector trigger, This box by default. A remote box lists over ssh through GET /dsh-oh-my-claude/box-dirs and creates folders through its POST; Open on a remote path pins a dsh workspace to it through POST /dsh-oh-my-claude/remote-workspaces. With no SSH box saved, dsh's own dialog is untouched.
- name: dsh-oh-my-claude
id: claude-nova
config:
providerId: claude-code-nova
providerName: Nova
sshHost: nova
dshTools: false
Every turn runs ssh <host> claude -p --input-format stream-json …; the remote shell inherits none of this box's environment, so the invocation carries the workspace directory and the CLI's env with it, and the stream-json wire flows through the pipe untouched, so translator, approvals and control requests all behave as if local. The far side uses its own ~/.claude, so log in there (ssh <host>, then claude auth login in that terminal; it needs a browser); the status and diagnostics panels probe over the same ssh and report that box's binary, version and login, not this one's, and they follow the session's mount, not whichever instance registered the routes: pick a box's model in the picker and Diagnostics (including its claude doctor button) reports that box from the moment the session exists, before any prompt. It resolves the session's provider from dsh's own per-session model directory, since a session header carries no provider and a remote workspace path can equal a local one. A box's settings files are read and written over the same ssh (src/remote-fs.ts): the diagnostics config-file list, the settings editor, the scope list, the plugin list, the feature switches and every Tune row act on that box's ~/.claude/settings.json. The Instructions tab walks the same ssh: the CLAUDE.md list, the @ imports it follows and the editor behind a row all read that box's disk, from its own $HOME. A remote read answers the file's mtime and its text in one round trip, and distinguishes a missing file from an unreachable box; a remote write creates the parent, keeps a .bak and lands through a temp file, so a dropped connection cannot leave half a settings file behind. homeAt asks the box for its $HOME rather than assuming this PC's, since the account there is usually a different one. What does not cross yet: keeper survival (an ssh instance always spawns node-style, so a dsh restart ends its turns and resumes them with --resume like spawn: node), the dsh MCP bridge (its URL is this box's port), and the tabs still keyed to this box's disk (Browser, Memory, Rewind, and the workspace diff). SSH auth is BatchMode only, so a working key or agent must already reach the host. shq, sshArgs, sshInvocation and sshSpawner are in src/process.ts, covered by src/ssh.test.ts.
Not covered
- dsh's shell and file tools are not proxied (except
bashfor background jobs); Claude Code uses its own, under its own permission mode. - Claude Code sessions are not deleted when dsh sessions are.
Check
bun run validate
One command for the whole gate, in phases, because the steps are not interchangeable. Formatting runs first and it writes: formatting is a fix, not a finding, and failing a run on it before anything else has run wastes the pass. Lint, the tests and fallow's dead-code pass then run together: they share no state and never write, so serialising them only costs wall-clock. The build comes next, and typecheck last, after it, since tsc reads the .d.ts files the build emits, so the order is a real dependency rather than a preference. A conflict-marker scan closes it out: git grep over tracked files only, so ignored directories drop out for free and a stray blob in someone's local cache cannot fail the gate. lib/ is excluded because it is generated from src/, where a real marker would already have been caught. Each step is quiet when it passes and prints its output only when it fails; the run ends with a per-step table.
Lint, format and typecheck all cover src/ and tools/: the dev scripts are held to the same compiler and the same rules as the plugin, so a check script cannot rot into a different dialect from the code it checks. tools/ has three exemptions of its own, on the same per-rule terms as src/. Two are for the oxlint plugin, which walks oxlint's AST generically and is therefore the parse boundary the anti-slop rules keep asking it to move the work to; the third is dsh-session-repair.ts, whose row types carry an index signature because it rewrites dsh's session logs and has to pass through every field it does not read.
Dead code is fallow, configured in .fallowrc.json, and it is report-only, never fallow fix. Run it alone with bun run deadcode. Every suppression in that file names the blind spot it covers, and the two biggest are structural rather than sloppy: nothing in the repo imports tools/ (they are optional dev checks, so each one is declared an entry point), and the tool-facing half of the API is reached through lib/, which is ignored because it is build output. src/adapter.ts is declared an entry point for the same reason one step up: dsh imports name, inject and apply from it by name through the Cordis protocol, so nothing in this repo references them. An auto-fix reading either as dead would delete the tools/ scripts wholesale. Two findings are left standing on purpose, each with its reason beside it in the config: SubprocessHandle is defined twice because dsh.ts mirrors dsh's own type verbatim while process.ts declares the narrower seam the node spawner can also answer, and the six import cycles through adapter.ts are demoted to a warning as known debt: a gate that is permanently red is a gate everyone learns to ignore.
The tests are every *.test.ts under src/ and src/client/. The suite is a glob rather than a list, so a new test file runs from the moment it is written. Covers config defaults, model resolution, session id derivation, config-dir resolution, turn selection, argument building, stream-json translation including native tool rows, the catalog fallback, the box settings proxy, the remote-fs scripts and their local branch, the transcript conversion (turn folding, tool result pairing, listing filters), session-list paging, the restore filter and the spinner verb helpers. UI checks that need a browser are the Playwright scripts under tools/playwright.
The suite fakes the CLI, so it cannot see a box whose claude is older than this one. tools/live-cli-check.ts closes that gap against the real binaries:
bun tools/live-cli-check.ts # every flag the plugin would send is a flag that binary has
bun tools/live-cli-check.ts --live # also runs one real turn per target, which spends tokens
It checks the local claude plus every box a remote workspace names, using the same argument builder, the same SSH invocation and the same stored box login the plugin spawns with. An unreachable host is skipped; a flag the target does not have is a failure. Development check only, like Playwright.
Roadmap
What comes next is tracked in the owner's working notes, which are not part of this repository.
Bugs and feedback
Found a bug, or something that reads wrong? Open an issue at github.com/lcestou/dsh-oh-my-claude/issues with the dsh and Claude Code versions (dsh --version, claude --version) and what you expected to see.
License
MIT.
Claude, Claude Code and the Claude spark mark are trademarks of Anthropic, PBC. This project is an independent dsh plugin that drives the Claude Code CLI you already have; it is not made, endorsed or supported by Anthropic. The spark is drawn here only to say which sessions and controls belong to Claude Code, in the way the CLI itself draws it.
No comments yet. Be the first to write one.