Files
Triple-C/ROADMAP.md
T
shadow-testandClaude Opus 5 dd2894cc60 Stop Docker disk growth, and fix the Claude Code settings that never worked
Two independent sets of fixes.

## Disk: stop the growth, no UI this round

The dangling-snapshot sweep was already correct and was never the leak. The
leak is that every `docker commit` **stacks** a layer and nothing compacts one:
a file deleted after it has been committed becomes a whiteout, not free bytes.
24 conditions trigger recreation+commit, so changing one settings field costs a
multi-gigabyte layer for the life of the project. One project was measured with
14 stacked commit layers, ~5.1 GB above its base.

* **Scrub the writable layer before every commit** (`docker/container.rs`,
  `SNAPSHOT_SCRUB_PATHS` / `scrub_writable_layer`). The one moment those bytes
  are still free to drop is before the commit that captures them. Measured on
  one container's 4.48 GB pending layer: 3.0 GB of agent scratchpad under
  `/tmp/claude-*`, the terminal drag-drop staging area (256 MiB per file, with
  no `rm` for it anywhere in the repo), a PNG per pasted image, and the apt
  lists/cache/logs that `browser_view/install.rs` and `triple-c-playwright-heal`
  leave behind with no `apt-get clean`. A hardcoded list, never a heuristic:
  `/workspace/{mount_name}` is a host bind mount and nothing here may reach one,
  and the three `/tmp` globs cannot select the read-only `.host-ca`/`.host-aws`
  mounts. Failure is a log line — a scrub must never block a snapshot.

* **Cap container logs** (`capped_log_config`). There was no `LogConfig`
  anywhere, so containers ran on the daemon's unbounded `json-file` default.
  Deliberately *not* wired into `container_needs_recreation`: participating
  would recreate every project once, and a recreation costs a commit, which is
  the thing being fixed. Picked up on the next natural recreation.

* **Make superseded base images sweepable** (`container/Dockerfile`). It carried
  no `LABEL` at all, so `orphan_sweep_filters`' `dangling` + `triple-c.managed`
  pair provably could not match one — ~11.9 GB observed stranded. Stamping
  `triple-c.managed=true` is the whole fix; the sweep needed no change.
  `create_container` writes the new `triple-c.base` key explicitly empty, or
  Docker's label inheritance plus `docker commit` would make every snapshot
  claim to be a base image. `force: false` stays, and now says why.

* **Sweep at startup** (`lib.rs`), not only after recreation: probes first
  (a probe pins an image the unforced sweep then refuses), pins second, sweep
  last. `sweep_orphaned_snapshots_logged` exists because all three callers threw
  the report away — `reclaimed_bytes`, `failed` and `unavailable` included.

* **Reap migration leftovers.** `rollback_migration` retagged and orphaned the
  migrated snapshot with no sweep. Stale `pre-migration-*` pins are now
  age-reaped by scanning the tag pattern rather than trusting the state file —
  `migration_store::load` reports an unparseable record as absent, which
  stranded a 4-12 GB pin nothing could name again; `load` now moves a corrupt
  record aside so `has_record` is trustworthy. A pin whose migration is still
  awaiting confirmation is never reaped at any age. The probe container's
  removal was a plain statement after an await, so a dropped future (an app quit
  mid-migration) leaked a container pinning a multi-gigabyte image; it is a
  `Drop` guard now, with `reap_probe_containers` for the case where the process
  itself dies.

* **Prune scheduler logs.** `remove` deleted a task's JSON but never its log
  directory, and the task runner appended uncapped `claude -p` output.

* **Fix the delete copy.** It said "the container, config volume, and stored
  credentials"; it removes *both* volumes and the snapshot image.

No prune UI, and no unfiltered `prune_images`/`prune_volumes` anywhere — the
daemon is shared with the user's unrelated work.

## Claude Code settings: two invented keys, one inverted default, one sticky bug

Verified against code.claude.com/docs/en/settings-reference.md and env-vars.md.

* `effort` -> **`effortLevel`**, the key Claude Code actually reads; the old one
  was written and silently ignored. `xhigh` added to the dropdown.
* `focusMode` -> **`viewMode: "focus"`**. `focusMode` was invented. The real key
  does exactly what the existing UI hint already described.
* **Session recap was inverted.** Claude Code's recap is on by default, so
  `CLAUDE_CODE_ENABLE_AWAY_SUMMARY=1`-when-enabled was a no-op and the control
  could never turn the recap *off*. The field is renamed to
  `session_recap_disabled` rather than reused: reusing the name with the
  opposite meaning would have read every stored `enable_session_recap: false` —
  which is every project that never touched the control — as "the user turned
  this off".
* **The stickiness, which is the important one.** Keys were emitted only when
  non-default, and the entrypoint *merges* into a settings.json on a persisted
  volume, so switching a setting off omitted its key, the merge preserved the
  stale on-value, and the setting stayed on until a destructive Reset. The fix
  already existed in the same file — the sandbox block is emitted
  unconditionally for exactly this reason — and is now applied to all five keys.
  A key whose neutral state is *unset* (`tui`, `effortLevel`, `viewMode`,
  `awaySummaryEnabled`) is emitted as JSON `null` and the entrypoint deletes it,
  because a stand-in value is not neutral: `tui: "default"` pins the classic
  renderer where unset lets Claude Code choose, and `viewMode: "default"`
  overrides the user's own sticky `/focus` choice.
* The same stickiness existed, unnoticed, in the **env vars**: `docker commit`
  bakes container env into the snapshot image, so a `=1` written once rode it
  forever. All four are now emitted on every create, extracted into
  `claude_code_env_vars` and unit tested. Two use an empty value for "off"
  rather than `0`, because they outrank a setting the user can change from
  inside their own container and Triple-C's default must not overrule a
  `/config` choice it never asked about.
* TUI mode is now a genuine three-way choice (automatic / classic / fullscreen),
  which the always-emitted key makes both necessary and possible.

`merge_claude_code_settings` is untouched by choice: a project-level OFF still
cannot override a globally-ON setting.

Tests: 364 frontend (+5), 308 Rust (+23), covering the scrub path list and
script, log rotation, pin reaping, and that toggling a setting off actually
clears a previously-set ON value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 08:35:10 -07:00

266 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Triple-C Roadmap — Claude Code Feature Parity
**Date:** 2026-08-09 · **Baseline:** v0.3.0 · **Claude Code reference:** 2.1.226
Companion to [DESIGN-REVIEW.md](DESIGN-REVIEW.md), which covers visual design and
information architecture. This document covers *which Claude Code capabilities Triple-C
should surface, and why.*
---
## Guiding principle
> **Triple-C shows state and launches things. Claude Code edits its own config.**
Triple-C's built-in MCP server management was removed in this cycle because Claude Code
absorbed the capability natively (`claude mcp add/list/remove`, `.mcp.json`, `/mcp`).
Hooks, skills, agents, plugins, output styles, and statusline are the same species: files
under `.claude/` with first-class Claude Code TUIs. Building GUI form editors for them
means losing the same race again.
What Claude Code cannot do is what Triple-C uniquely owns: **the container boundary and
what persists behind it** — the config volume, workspace mounts, lifecycle, the bundled
scheduler, and the fleet view across many projects.
---
## Current coverage (v0.3.0)
Triple-C sets exactly six `settings.json` keys, plus a sandbox block:
| Key | Surfaced as |
|---|---|
| `tui` | TUI mode select — unset (Claude Code chooses), `default` (classic renderer), `fullscreen` (flicker-free alt-screen). Three distinct states, not two. |
| `effortLevel` | Effort level select (`low`/`medium`/`high`/`xhigh`) |
| `viewMode` | Focus mode toggle, written as `"focus"`. Unset means the user's own `verbose` setting and sticky `/focus` choice still apply. |
| `autoScrollEnabled` | Auto-scroll toggle. Claude Code's default is `true`, so it is the *off* state that writes `false`. |
| `showThinkingSummaries` | Thinking summaries toggle (Claude Code default `false`) |
| `awaySummaryEnabled` | Session recap toggle. Claude Code's recap is **on** by default, so again it is the off state that writes `false`. |
| `sandbox.*` | Sandbox toggle (`enabled`, `enableWeakerNestedSandbox`, `allowUnsandboxedCommands`) |
Every one of those keys is emitted on **every** start, with a JSON `null` standing for
"delete this key". `~/.claude/settings.json` sits on the config volume and the entrypoint
merges into it, so a key merely omitted when its control goes off left the previous
on-value in place forever.
Plus four env feature flags — `CLAUDE_CODE_NO_FLICKER`, `CLAUDE_CODE_ENABLE_AWAY_SUMMARY`,
`CLAUDE_CODE_SUBPROCESS_ENV_SCRUB`, `ENABLE_PROMPT_CACHING_1H` — and arbitrary user-set
`CLAUDE_CODE_*` vars via the Env Vars modal. The four are written on every container
create *including* their off value, because `docker commit` bakes a container's env into
the snapshot image: a value written once would otherwise ride that snapshot into every
future container. That also makes them Triple-C's to own, so all four are reserved names
— hand-setting one in the Env Vars modal is skipped with a warning, the same as any other
`triple-c.*`-managed variable. `CLAUDE_CODE_ENABLE_AWAY_SUMMARY` is what actually enforces
the recap choice — it takes precedence over `awaySummaryEnabled` *and* over the
in-container `/config` toggle, so turning the control off sends `0` while leaving it on
sends an empty value rather than `1`: Triple-C's default must not overrule a `/config`
choice it never asked about.
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
container start, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
file drag-drop, STT), the web terminal, and workspace backup.
---
## Gap analysis
### Committed for this cycle
| # | Gap | Today | Plan |
|---|---|---|---|
| 1 | **Permission modes** | one boolean → `--dangerously-skip-permissions` | Four-state control (Plan / Default / Accept Edits / Bypass) → `--permission-mode`. Verified choices on 2.1.226: `acceptEdits`, `auto`, `bypassPermissions`, `manual`, `dontAsk`, `plan`. |
| 2 | **Session resume** | none | List sessions from the config volume; `[Resume]` opens a terminal on `claude --resume <id>`. |
| 3 | **Capability inventory** | none | Read-only counts + names for skills / agents / hooks / plugins / commands / native MCP servers. Deep-link to the terminal to manage. |
| 4 | **Automation** | `triple-c-scheduler` ships in every container with *zero* UI | Task list, cron editor, run-now, logs, notification badges. |
| 5 | **Container auth handoff** | manual code paste | See "Authentication handoff" below — design decision pending. |
### Deliberately skipped
Status line builder · output-styles editor · hook *editors* · checkpoint/rewind browser ·
plugin marketplace browser. Each is niche, natively handled by Claude Code's own TUI, or a
settings-editor trap. Surface counts and deep-link instead.
### Not yet scheduled
- Granular `permissions.allow` / `ask` / `deny` rules and `additionalDirectories`
- Sandbox detail settings (`filesystem.allowRead/allowWrite`, `allowedDomains`,
`excludedCommands`) — currently documented for hand-editing via `SANDBOX_INSTRUCTIONS`
- Project-level `.claude/settings.json` vs user-level settings hierarchy
- A model picker. **Note:** the only model strings in the app today are stale placeholders
(`anthropic.claude-sonnet-4-20250514-v1:0` in `AwsSettings.tsx` and `ProjectCard.tsx`,
`qwen3.5:27b`, `gpt-4o / gemini-pro / etc.`). These are free-text placeholders, not
dropdowns, but they should be refreshed to current model identifiers regardless.
- The container's settings.json merge is **shallow** (`jq -s '.[0] * .[1]'`), so a
user-authored nested block such as `sandbox.filesystem.allowWrite` is replaced wholesale
on every container start. Worth deepening to `*` recursive merge.
---
## Authentication handoff
**Goal:** stop making users hand-copy an auth code into every container.
**Constraint discovered during research:** `claude login`'s callback server uses an
**ephemeral port** and its redirect URI is **not configurable** for the main login flow
(`--callback-port` and `oauth.callbackPort` apply to *MCP server* OAuth only). So a design
that pre-assigns each container a fixed callback port and routes to it cannot work as
stated — there is no fixed port to route.
There is also a known container gotcha: on Linux, Node resolves `localhost` to IPv6 first,
so the callback server may bind `[::1]:PORT` only and be unreachable over IPv4
([anthropics/claude-code#44844](https://github.com/anthropics/claude-code/issues/44844)).
Two viable options:
### Option A — long-lived token injection (simple)
`claude setup-token` (verified present on 2.1.226: *"Set up a long-lived authentication
token (requires Claude subscription)"*) returns a ~1-year OAuth token. Triple-C runs it in
a running container, stores the token in the OS keychain via the existing `secure.rs`, and
injects `CLAUDE_CODE_OAUTH_TOKEN` into every container on the Anthropic backend.
**Correction to an earlier assumption in this document.** `setup-token` does *not* start a
loopback callback listener, so it does not need the Auth Bridge. Verified by running it
under a pty: its `redirect_uri` is Anthropic-hosted
(`https://platform.claude.com/oauth/code/callback`), the user copies a code off that page,
and the CLI blocks at a `Paste code here if prompted >` prompt on **stdin**. A stdin path
is therefore mandatory — the flow cannot complete without one.
- No routing, no ports, no proxy.
- One auth event covers every project.
- Cost: small. Reuses existing keychain and env-injection plumbing.
- Limits: token is subscription-scoped and expires annually; per the docs a `setup-token`
token cannot drive Remote Control sessions or claude.ai connector fetches.
Change detection uses a **random rotation id** in the `triple-c.claude-token-version`
label, not a hash of the token. Labels are readable by anything that can run
`docker inspect`, so a hash would be an offline verification oracle — given a candidate
token you could confirm it. A presence boolean would instead miss rotations and silently
leave containers on a stale token.
### Option B — the Auth Bridge (general loopback-callback bridge)
Option A only solves Claude Code. The same problem affects every CLI that authenticates by
starting a temporary loopback listener and opening a browser at a URL that redirects back
to it — Concourse `fly login` (random loopback port serving `/auth/callback`),
`aws sso login`, and many others. Inside a container the host browser cannot reach that
listener, so login stalls.
Because the ports are ephemeral and unconfigurable, nothing can be pre-assigned. The bridge
**discovers** listeners instead:
1. While enabled for a running project, poll the container for loopback TCP listeners by
reading `/proc/net/tcp` and `/proc/net/tcp6` over `docker exec` — no dependency on
`ss`/`netstat`/`lsof`, which aren't guaranteed in the image.
2. For each newly-appeared loopback listener, bind **the same port on the host's
`127.0.0.1`** (never `0.0.0.0` — that would expose container internals to the LAN).
3. Proxy each accepted connection into the container over the Docker API via
`socat - TCP:127.0.0.1:<port>` (socat already ships in the image), reusing the existing
attached-exec streaming in `docker/exec.rs`. Going through the Docker API rather than a
container IP keeps this working on Docker Desktop, where container IPs are not routable
from the host.
4. Fall back to `TCP6:[::1]:<port>` when the listener appeared only on IPv6 — on Linux,
Node resolves `localhost` to IPv6 first, so `claude login` frequently binds `::1` only
([anthropics/claude-code#44844](https://github.com/anthropics/claude-code/issues/44844)).
5. Tear down when the listener vanishes, the container stops, the bridge is disabled, or
the app exits. Ports already covered by the project's explicit port mappings are skipped;
host-side conflicts are reported rather than silently swallowed.
Opt-in per project (`auth_bridge_enabled`, default off), since it makes container-internal
loopback services reachable from the host.
**Plan:** ship **A** for Claude Code specifically — it removes the pain for the common case
at a fraction of the cost — and **B** as the general mechanism covering every other CLI.
They compose: A means most users never trigger a browser login at all; B catches AWS SSO,
Concourse, and anything else that needs a real callback.
---
## Sequencing
**Phase 0 — done.** Remove MCP (frontend, backend, entrypoint, docs) with a self-healing
migration for containers created against the old per-project Docker network.
**Phase 1 — foundations.** Permission modes end-to-end (including the scheduler bug fix
below). Read-only introspection backend: sessions, capabilities, scheduler.
**Phase 2 — Tier-1 polish.** Focus rings, contrast fixes, real buttons, inline start/stop
progress, status labels, onboarding welcome screen, shared accessible `<Modal>`.
**Phase 3 — Project Home.** Move project config out of the sidebar card into a tabbed
main-area view (Overview / Sessions / Automation / Config), dissolving the modal pile and
splitting the 1,257-line `ProjectCard`.
**Phase 4 — authentication handoff.** Option A, then evaluate B.
**Phase 5 — Library.** Global skills/agents/commands with per-project enable, synced into
the config volume by the entrypoint. Generalizes the pattern the MCP tab was reaching for.
---
## Bugs found during this review
1. **Scheduled tasks ignore the project's permission setting.**
`container/triple-c-task-runner:69` runs
`claude -p "$PROMPT" --dangerously-skip-permissions` unconditionally, regardless of the
project's Full Permissions toggle. Being fixed as part of Phase 1.
2. **Docs claim Reset preserves credentials; it does not.**
`rebuild_project_container` calls `remove_project_volumes`, which deletes both
`triple-c-home-{id}` (holding `~/.claude.json`) and `triple-c-claude-config-{id}`
(holding `~/.claude`). README.md, HOW-TO-USE.md, and CLAUDE.md all still state that
OAuth tokens survive a Reset. Pre-existing; not yet corrected.
3. **An invalid cron expression silently unscheduled every task.** Found while adding
task creation to the Automation tab, and the most serious bug in this review.
`triple-c-scheduler` never validated `--schedule`, and `rebuild_crontab` regenerates the
*entire* crontab and pipes it to `crontab`, which rejects the whole file if any single
line is malformed — with the error discarded by `2>/dev/null || true`. So one bad
schedule silently unscheduled every other task in the container, reporting success.
Reproduced directly. This mattered because the global CLAUDE.md instructs Claude to use
this CLI, so Claude itself could trigger it. Fixed at the root: `add` now validates the
expression and exits non-zero, and `rebuild_crontab` reports a rejected crontab instead
of swallowing it. The Rust `add_scheduled_task` command validates independently.
4. **Reset was destructive with no confirmation.** It deletes both volumes — the login,
installed skills, all session transcripts — from a single unconfirmed click, while the
comparably destructive Remove already confirmed. Now gated by a dialog that names each
loss. Fixed.
5. **Cancelling authentication did not cancel.** Fixed — see the handoff section above.
6. **Stale model placeholders** — see "Not yet scheduled" above.
7. **Silent save failures.** Project config saves on blur; failures went only to
`console.error`. Fixed in Phase 3 — `useProjectSave` now renders a
Saved / Saving / Save failed indicator and raises a toast.
---
## Known gaps left by Phase 23
- **Editing a scheduled task changes its id.** `triple-c-scheduler` has no `edit`
subcommand, and hand-editing its JSON behind its back would desync the crontab, so edit is
implemented as add-then-remove. The add runs first, so a rejected edit leaves the original
intact. The task gets a new id and its older logs stay under the old one; the editor says
so before saving.
- **`open_terminal_session` takes no command argument.** "Resume session" and
"Manage in terminal" therefore open a bash tab and *type* the command after a
fixed prompt delay. It works, but it is timing-dependent and will misfire on a
slow container start. The fix is a `command: Option<String>` parameter on the
Tauri command so the exec launches the process directly.
- **Uptime is observed, not reported.** `get_container_info` returns a status enum
with no start time, so Project Home records "running since" when the app *sees*
the transition. A container already running when the app launches shows
`● Running` with no elapsed time. Surfacing Docker's `State.StartedAt` would fix it.
- **`lucide-react` was not adopted** (DESIGN-REVIEW Tier-1 #9) — no package-registry
access in the build environment used for this cycle. The existing inline SVGs and
text glyphs remain.
- **The tab strip stayed in the TopBar** rather than moving onto the terminal panel's
top edge. DESIGN-REVIEW §A6 asks for the move but its own §B2 layout diagram puts
the tabs in the TopBar; the diagram won. Worth revisiting.
- **`Ctrl+Shift+W`, not `Ctrl+W`, closes a tab.** Plain `Ctrl+W` is readline's
`kill-word`, used constantly inside the terminal this app is built around;
intercepting it globally would break word-erase in every shell.