28 Commits

Author SHA1 Message Date
1a5fbd6be4 Merge pull request 'UX: collapsible sidebar, settings accordion, global backend defaults, tab rename' (#4) from feature/ux-improvements into main
All checks were successful
Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 1m1s
Build App / build-macos (push) Successful in 2m30s
Build App / build-windows (push) Successful in 2m50s
Build App / build-linux (push) Successful in 4m47s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
Reviewed-on: #4
2026-05-24 16:39:34 +00:00
2fa6abeae0 Allow renaming terminal tabs (persisted per project)
All checks were successful
Build App / compute-version (pull_request) Successful in 3s
Build App / build-windows (pull_request) Successful in 5m33s
Build Container / build-container (pull_request) Successful in 7m58s
Build App / build-linux (pull_request) Successful in 4m51s
Build App / build-macos (pull_request) Successful in 2m39s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Right-click a tab (or double-click) to rename. Renamed labels show
as "ProjectName: CustomName" and are stored in the project's
renamed_session_names map. The entry is cleared on tab close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:50:48 -07:00
5b1c801cf1 Add global backend defaults with runtime fallback
New fields: GlobalAwsSettings.default_model_id, plus
GlobalOllamaSettings and GlobalOpenAiCompatibleSettings (base_url +
default_model_id each). When a per-project base_url or model_id is
blank, the container env vars and config fingerprints fall back to
the global value. Container recreation is triggered whenever the
resolved value changes, so editing a global default updates existing
projects on next start.

UI: added the new fields to AwsSettings and two new global settings
components, slotted into the Backends accordion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:49:06 -07:00
9b78b4bc62 Group Settings panel into accordion sections
Multiple-open accordion with per-section state persisted to
localStorage. Sections: General, Backends, Container, Git/SSH,
Tools, Updates. General is open by default; the rest are collapsed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:42:18 -07:00
7acc8b8d39 Add collapsible sidebar with icon rail
Persist collapsed state in localStorage. When collapsed, render a
narrow rail with Projects/MCP/Settings icon buttons that expand the
sidebar to that view on click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:40:54 -07:00
7840bddbb4 Sync bundled mission-control to upstream 15fbc94
Pulls in 15 upstream commits since the April 3 bundling snapshot
(msieurthenardier/mission-control). Notable changes:

- agentic-workflow rewritten as the "fast" variant: per-leg design and
  implement, single review and commit across the whole flight
- New Skill-Project Boundary section: skills no longer read or write
  project-owned artifacts by literal heading
- routine-maintenance scoped to post-mission only; adds state-machine
  reachability and cache freshness audits
- Test metrics capture threaded through debrief, maintenance, and flight
- Crew prompts no longer carry skill-required instructions; SKILL.md is
  the protocol
- Worktree git strategy removed; standardized on {target-project}
- Jira artifact template removed upstream

Local URL correction in init-project/README.md preserved
(anthropics/flight-control -> msieurthenardier/mission-control).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:10:32 -07:00
4588bdf40c Make macOS release upload idempotent across re-runs
All checks were successful
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m25s
Build App / build-windows (push) Successful in 4m42s
Build App / build-linux (push) Successful in 8m54s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Previous fix only addressed the network flake; a re-run after any
upload failure still tripped over the leftover release record. The
naive POST /releases got 409 from Gitea, the grep-pipe parser yielded
an empty RELEASE_ID, and pipefail aborted with an opaque exit 1.

Now:
- Look up the release by tag first; reuse on 200, create on 404, fail
  loudly on anything else.
- Validate RELEASE_ID is non-empty and surface the response body if
  parsing fails.
- Before uploading each asset, check whether the release already has
  an asset with that name (from a partial prior run) and DELETE it so
  the POST is replace-not-conflict.
- Set -euo pipefail explicitly so the script's failure modes are
  predictable rather than dependent on the runner's default flags.

Network hardening from the previous commit (HTTP/1.1, retries, -f) is
preserved. Linux and Windows blocks unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:13:25 -07:00
b607cf3681 Harden macOS release upload against curl exit 92
Some checks failed
Build App / compute-version (push) Successful in 3s
Build App / build-windows (push) Successful in 4m5s
Build App / build-linux (push) Successful in 9m53s
Build App / build-macos (push) Failing after 2m30s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
macOS upload has been intermittently failing with curl exit 92
("HTTP/2 stream not closed cleanly") for several releases (v0.3.12,
v0.3.10, v0.3.1 all landed with empty asset arrays despite the per-tag
release record being created). It is not a size issue — Linux uploads
the 81MB AppImage on the same Gitea instance without trouble while the
Mac dmg is only 13.6MB.

Adds `--http1.1` to sidestep HTTP/2 stream multiplexing flakes on the
macOS runner, `-f` so HTTP errors no longer fail silently under `-s`,
and `--retry 5 --retry-all-errors --retry-delay 5 --max-time 600` to
absorb transient drops. Linux and Windows blocks unchanged; an inline
note in the YAML calls out where to mirror this if those start
failing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:28:08 -07:00
21a85dc977 Bump @tauri-apps/api and @tauri-apps/cli to 2.11.0 in package-lock
Some checks failed
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Failing after 3m24s
Build App / build-windows (push) Successful in 4m9s
Build App / build-linux (push) Successful in 7m20s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
Mac/Windows release builds failed the Tauri version-mismatch check:
tauri (2.11.0) vs @tauri-apps/api (2.10.1). The Linux fix only updated
the Rust lockfile; the npm lockfile was still at 2.10.x. Both lockfiles
now resolve to 2.11.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:09:22 -07:00
272eb28863 Bump tauri Rust crate to 2.11.0 to match @tauri-apps/api
Some checks failed
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Failing after 6s
Build App / build-windows (push) Failing after 24s
Build App / build-linux (push) Successful in 6m52s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
CI's pre-build version check failed: tauri (2.10.2) vs @tauri-apps/api
(2.11.0). Both the Cargo.toml and package.json caret-pin to 2, so this is
purely a lockfile resolution fix — `cargo update -p tauri --precise
2.11.0` brings the Rust side up to match. Schema regeneration is included
since the gen/schemas/ output is keyed to the Tauri version.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:32:02 -07:00
1ef6efca9f Merge pull request 'feature/docker-install-helper' (#3) from feature/docker-install-helper into main
Some checks failed
Build App / compute-version (push) Successful in 2s
Build Container / build-container (push) Successful in 1m1s
Build App / build-linux (push) Failing after 2m0s
Build App / build-macos (push) Failing after 3m27s
Build App / build-windows (push) Successful in 4m8s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
Reviewed-on: #3
2026-05-01 20:01:58 +00:00
5974347913 Add per-project sandbox mode and Bedrock service-tier
Some checks failed
Build App / compute-version (pull_request) Successful in 2s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-windows (pull_request) Successful in 8m1s
Build Container / build-container (pull_request) Successful in 8m11s
Build App / build-linux (pull_request) Failing after 1m53s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Sandbox mode: new per-project toggle that turns on Claude Code's bash
sandbox inside the container. Adds `bubblewrap` and `socat` to the
Dockerfile (the two Linux deps required by the sandbox), and emits a
managed `sandbox` block into `~/.claude/settings.json` via the existing
CLAUDE_CODE_SETTINGS_JSON entrypoint merge:

- `enabled` mirrors the Triple-C toggle and is always emitted, so the
  entrypoint's recursive jq merge clears any prior on-state from the
  persisted named volume — Triple-C is authoritative.
- `enableWeakerNestedSandbox: true` because we run inside Docker without
  privileged user namespaces.
- `allowUnsandboxedCommands: false` to disable the `dangerouslyDisableSandbox`
  escape hatch — opting into the sandbox shouldn't come with a runtime
  bypass.

When sandbox is on, a SANDBOX_INSTRUCTIONS section is appended to
CLAUDE_INSTRUCTIONS so Claude can guide users through allowing extra
paths/domains, excluding `docker *`/`watchman *` from the sandbox, and
the rule that `sandbox.enabled` is owned by Triple-C. The Claude-Code
settings fingerprint includes sandbox state (only when on, to avoid
spuriously flagging existing containers for recreation on upgrade).

Bedrock service tier: new optional field on the per-project Bedrock
config. When set, exported as ANTHROPIC_BEDROCK_SERVICE_TIER (added in
Claude Code 2.1.122) and included in the Bedrock fingerprint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:58:54 -07:00
805f815876 Regenerate Tauri ACL schemas after dialog plugin update
Picks up the deprecation notes on dialog `ask`/`confirm` permissions
(now aliased to `allow-message`/`deny-message` and slated for removal
in Tauri v3). No behavior change — generated artifacts only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:57:22 -07:00
5360f22b65 Make preview build workflow manual-only
Trigger is workflow_dispatch exclusively so builds happen only when
explicitly requested from the Actions UI, not on every branch push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:22:11 -07:00
0316234329 Add preview build workflow for non-main branches
Some checks failed
Build App (Preview) / compute-version (push) Successful in 2s
Build App (Preview) / build-macos (push) Failing after 2m28s
Build App (Preview) / build-windows (push) Failing after 4m29s
Build App (Preview) / build-linux (push) Failing after 8m4s
Mirrors build-app.yml's three-platform matrix (Linux/macOS/Windows)
but uploads the bundles as workflow artifacts instead of creating
Gitea releases or syncing to GitHub, so feature branches can be
smoke-tested without cluttering the release streams.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:21:17 -07:00
ee68cc820c Add Docker install helper for first-run setup
When Docker isn't detected on startup, surface a dialog offering a
one-click install (pkexec + get.docker.com on Linux, brew cask on
macOS, winget on Windows) with a graceful fallback to manual steps
and a link to official documentation. Install output streams back
to the UI via a tauri event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:18:46 -07:00
7f6655fbcf Trim whitespace on terminal copy by default, keep raw copy on Ctrl+Shift+Alt+C and right-click menu
All checks were successful
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m31s
Build App / build-windows (push) Successful in 4m39s
Build App / build-linux (push) Successful in 5m42s
Build App / create-tag (push) Successful in 9s
Build App / sync-to-github (push) Successful in 17s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:58:56 -07:00
b907ad0239 Add breathing room to terminal bottom-left so STT button clears Claude Code status
All checks were successful
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m29s
Build App / build-windows (push) Successful in 4m20s
Build App / build-linux (push) Successful in 5m45s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 16:27:30 -07:00
de1d809de5 Update Flight Control reference URL to mission-control repo
All checks were successful
Build Container / build-container (push) Successful in 1m13s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 15:45:29 -07:00
3c7852544b Fix TUI fullscreen mode cutting off Claude Code status line
All checks were successful
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m31s
Build App / build-windows (push) Successful in 3m56s
Build App / build-linux (push) Successful in 5m5s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 16s
Add bottom padding to terminal containers so FitAddon proposes one
fewer row, leaving visible space below Claude Code's mode indicator.
Previously the bottom status line (e.g. "bypass permissions on") was
clipped against the container edge in fullscreen TUI mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 15:44:10 -07:00
ddf44d97e5 Fix Docker build: manual NodeSource setup + retry loops on all apt-get updates
All checks were successful
Build Container / build-container (push) Successful in 41m2s
The previous fix wasn't enough: the NodeSource setup_22.x script runs its
own internal `apt-get update` without retries. When that hit the Ubuntu
mirror-sync issue (stale Packages.gz with mismatched hash), the script
silently bailed without configuring the NodeSource repo. The next
`apt-get install -y nodejs` then installed Ubuntu's default nodejs 18,
which ships without npm, breaking the `npm install -g pnpm` step.

Changes:
- Replace the `curl ... | bash -` NodeSource setup with manual GPG key +
  repo file configuration, giving us direct control over apt-get update
  retries.
- Add the same 5-attempt retry loop (with 10s sleep and lists cleanup)
  to the Python 3 and Docker CLI steps, since both also do an
  apt-get update and would hit the same failure mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:50:42 -07:00
d60124f1bd Fix CI: harden version computation and Dockerfile apt retries
Some checks failed
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m44s
Build App / build-windows (push) Successful in 5m18s
Build App / build-linux (push) Successful in 46m30s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 11s
Build Container / build-container (push) Failing after 3m14s
Two fixes for the v0.3.x initial build failures:

1. **Compute Version step**: When no tags match v0.3.*, `grep` returns
   exit 1 which under `pipefail` killed the step before the empty-tag
   fallback could run. Added `|| true` to the pipeline so the fallback
   (`git rev-list --count HEAD`) runs correctly on first 0.3.x build.

2. **Dockerfile apt-get update**: Transient archive.ubuntu.com mirror
   sync failures (stale Packages.gz with mismatched hash) broke the
   GitHub CLI install step. Added a shell retry loop (5 attempts with
   10s sleep, clearing /var/lib/apt/lists/* between retries) to both
   the main system packages step and the GitHub CLI step, plus
   Acquire::Retries=3 on the other apt-get update calls for transient
   network failures.

Also includes the Cargo.lock 0.2.0 → 0.3.0 rev that went with the
previous version bump commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:09:30 -07:00
4f23951379 Bump version to 0.3.0
Some checks failed
Build App / compute-version (push) Failing after 3s
Build App / build-linux (push) Has been skipped
Build App / build-macos (push) Has been skipped
Build App / build-windows (push) Has been skipped
Build Container / build-container (push) Failing after 15m59s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
## What's New in v0.3.0

### Claude Code Settings (TUI Mode, Effort, Focus, Caching)
- New per-project and global settings for Claude Code CLI behavior
- **TUI Fullscreen Mode**: Flicker-free alt-screen rendering via CLAUDE_CODE_NO_FLICKER
- **Effort Level**: Control reasoning depth (low/medium/high)
- **Focus Mode**: Collapse tool output to one-line summaries
- **Thinking Summaries**: Show Claude's thinking process
- **Session Recap**: Get context when returning to a session
- **Auto-Scroll Disabled**: Disable auto-scroll in fullscreen TUI
- **Env Scrub**: Strip credentials from subprocess environments
- **Prompt Caching (1h)**: Enable 1-hour prompt cache TTL
- New ClaudeCodeSettingsModal accessible from project config and global settings
- Settings injected as env vars and ~/.claude/settings.json via entrypoint

### Session Naming
- Name Claude Code terminal sessions with the -n flag
- Session names displayed in terminal tabs instead of project name

### Global Default Fallbacks
- Global SSH key path now used when per-project SSH path is not set
- Global git name/email now used when per-project values are not set
- New UI in Settings panel for SSH key directory, git name, and git email

### Relaxed Environment Variable Filter
- CLAUDE_CODE_* env vars now allowed in custom env vars for power users
- Only specific internal vars (CLAUDE_INSTRUCTIONS, MCP_SERVERS_JSON, etc.) blocked

### Documentation
- Updated README, HOW-TO-USE, and CLAUDE.md with all new features
- New "Claude Code Tips" section documenting built-in CLI features
  (/focus, /recap, /color, /loop, /powerup, /team-onboarding, setup wizards)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:48:19 -07:00
d6ac3ae6c6 Add Claude Code settings infrastructure, TUI mode, session naming, and global defaults
Adds first-class support for Claude Code CLI features (2.1.71-2.1.110):

- New ClaudeCodeSettings struct with per-project and global defaults for
  TUI mode, effort level, focus mode, thinking summaries, session recap,
  auto-scroll, env scrub, and 1-hour prompt caching
- Settings injected as env vars (CLAUDE_CODE_NO_FLICKER, etc.) and
  ~/.claude/settings.json entries via entrypoint.sh merge block
- New ClaudeCodeSettingsModal component for configuring settings
- Session naming support (-n flag passed to claude CLI, shown in tabs)
- Relaxed reserved prefix filter: CLAUDE_CODE_* env vars now allowed in
  custom env vars UI for power users
- Global SSH key path, git name, and git email now used as fallbacks
  when per-project values are not set, with UI in SettingsPanel
- Fingerprint-based change detection triggers container recreation when
  Claude Code settings change
- Updated README, HOW-TO-USE, and CLAUDE.md documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:46:03 -07:00
ef67b447b3 Pre-validate AWS SSO session on host during container startup
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-linux (push) Successful in 4m46s
Build App / build-windows (push) Successful in 6m57s
Build App / build-macos (push) Successful in 9m9s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 12s
For Bedrock Profile projects, SSO credentials are now checked and
refreshed on the host before the container starts, so the entrypoint
copies already-valid tokens. This eliminates the delay where users
had to wait for the terminal to open before being prompted to login.

The terminal-time fallback remains for mid-session credential expiry.
Also consolidates duplicated profile resolution logic into a shared
helper in aws_commands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:54:59 -07:00
15b03173a5 Update README with Speech-to-Text documentation
Add STT section covering voice mode usage, hotkey (Ctrl+Shift+M), model
options, auto-start behavior, and transcription flow. Update Key Files
table with all STT-related files and fix outdated useVoice.ts reference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 06:40:45 -07:00
a0b4dca0bd Auto-start STT container on app launch when enabled in settings
All checks were successful
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m31s
Build App / build-windows (push) Successful in 4m40s
Build App / build-linux (push) Successful in 4m45s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Previously the STT container only started on-demand (mic button click or
manual start in settings). Now it auto-starts during app setup if
stt.enabled is true, matching the web terminal auto-start pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 06:34:36 -07:00
17c5d699f9 Merge pull request 'STT improvements: hotkey, button position, and hover tooltip' (#2) from feature/stt into main
All checks were successful
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m27s
Build App / build-windows (push) Successful in 4m4s
Build App / build-linux (push) Successful in 4m56s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
Reviewed-on: #2
2026-04-13 13:02:53 +00:00
71 changed files with 3814 additions and 977 deletions

View File

@@ -0,0 +1,317 @@
name: Build App (Preview)
# Builds the Tauri app for branches other than main and exposes the bundles as
# workflow artifacts. No Gitea release, no GitHub sync — intended for local
# smoke-testing of feature branches before they merge.
on:
workflow_dispatch:
jobs:
compute-version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.VERSION }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute preview version
id: version
run: |
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
SHORT_SHA=$(git rev-parse --short HEAD)
VERSION="${MAJOR_MINOR}.0-preview.${SHORT_SHA}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed preview version: ${VERSION}"
build-linux:
runs-on: ubuntu-latest
needs: [compute-version]
steps:
- name: Install Node.js 22
run: |
NEED_INSTALL=false
if command -v node >/dev/null 2>&1; then
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
OLD_NODE_DIR=$(dirname "$(which node)")
echo "Found Node.js $(node --version) at $(which node) (major: ${NODE_MAJOR})"
if [ "$NODE_MAJOR" -lt 22 ]; then
echo "Node.js ${NODE_MAJOR} is too old, removing before installing 22..."
sudo rm -f "${OLD_NODE_DIR}/node" "${OLD_NODE_DIR}/npm" "${OLD_NODE_DIR}/npx" "${OLD_NODE_DIR}/corepack"
hash -r
NEED_INSTALL=true
fi
else
echo "Node.js not found, installing 22..."
NEED_INSTALL=true
fi
if [ "$NEED_INSTALL" = true ]; then
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
hash -r
fi
node --version
npm --version
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set app version
run: |
# Tauri / Cargo require a strict semver; strip the preview suffix for
# the bundle version but keep it in the artifact filename.
BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)"
sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json
sed -i "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml
echo "Patched version to ${BASE_VERSION}"
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libsoup-3.0-dev \
libssl-dev \
libxdo-dev \
patchelf \
pkg-config \
build-essential \
curl \
wget \
file \
xdg-utils
- name: Install Rust stable
run: |
if command -v rustup >/dev/null 2>&1; then
rustup update stable
rustup default stable
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
export PATH="$HOME/.cargo/bin:$PATH"
rustc --version
cargo --version
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules package-lock.json
npm install
- name: Install Tauri CLI
working-directory: ./app
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri --version || npm install @tauri-apps/cli
- name: Build Tauri app
working-directory: ./app
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build
- name: Collect artifacts
run: |
mkdir -p artifacts
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
ls -la artifacts/
- name: Upload Linux artifacts
uses: actions/upload-artifact@v4
with:
name: triple-c-${{ needs.compute-version.outputs.version }}-linux
path: artifacts/
if-no-files-found: error
retention-days: 14
build-macos:
runs-on: macos-latest
needs: [compute-version]
steps:
- name: Install Node.js 22
run: |
NEED_INSTALL=false
if command -v node >/dev/null 2>&1; then
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
if [ "$NODE_MAJOR" -lt 22 ]; then
NEED_INSTALL=true
fi
else
NEED_INSTALL=true
fi
if [ "$NEED_INSTALL" = true ]; then
brew install node@22
brew link --overwrite node@22
fi
node --version
npm --version
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set app version
run: |
BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)"
sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json
sed -i '' "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml
echo "Patched version to ${BASE_VERSION}"
- name: Install Rust stable
run: |
if command -v rustup >/dev/null 2>&1; then
rustup update stable
rustup default stable
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
export PATH="$HOME/.cargo/bin:$PATH"
rustup target add aarch64-apple-darwin x86_64-apple-darwin
rustc --version
cargo --version
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules
npm install
- name: Install Tauri CLI
working-directory: ./app
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri --version || npm install @tauri-apps/cli
- name: Build Tauri app (universal)
working-directory: ./app
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build --target universal-apple-darwin
- name: Collect artifacts
run: |
mkdir -p artifacts
cp app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg artifacts/ 2>/dev/null || true
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
ls -la artifacts/
- name: Upload macOS artifacts
uses: actions/upload-artifact@v4
with:
name: triple-c-${{ needs.compute-version.outputs.version }}-macos
path: artifacts/
if-no-files-found: error
retention-days: 14
build-windows:
runs-on: windows-latest
needs: [compute-version]
defaults:
run:
shell: cmd
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set app version
shell: powershell
run: |
$raw = "${{ needs.compute-version.outputs.version }}"
$version = $raw.Split('-')[0]
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
Write-Host "Patched version to $version"
- name: Install Rust stable
run: |
where rustup >nul 2>&1 && (
rustup update stable
rustup default stable
) || (
curl -fSL -o rustup-init.exe https://win.rustup.rs/x86_64
rustup-init.exe -y --default-toolchain stable
del rustup-init.exe
)
- name: Install Node.js
run: |
where node >nul 2>&1 && (
node --version
) || (
curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi"
msiexec /i node-install.msi /quiet /norestart
del node-install.msi
)
- name: Verify tools
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
rustc --version
cargo --version
node --version
npm --version
- name: Install Tauri CLI via cargo
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo install tauri-cli --version "^2"
- name: Fix npm platform detection
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
npm config set os win32
npm config list
- name: Install frontend dependencies
working-directory: ./app
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
if exist node_modules rmdir /s /q node_modules
npm ci
- name: Build frontend
working-directory: ./app
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
npm run build
- name: Build Tauri app
working-directory: ./app
env:
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo tauri build
- name: Collect artifacts
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
mkdir artifacts
copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ 2>nul
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
dir artifacts\
- name: Upload Windows artifacts
uses: actions/upload-artifact@v4
with:
name: triple-c-${{ needs.compute-version.outputs.version }}-windows
path: artifacts/
if-no-files-found: error
retention-days: 14

View File

@@ -40,7 +40,8 @@ jobs:
echo "Major.Minor: ${MAJOR_MINOR}" echo "Major.Minor: ${MAJOR_MINOR}"
# Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes) # Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes)
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1) # `|| true` so an empty grep result doesn't fail the step under pipefail.
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
if [ -n "$LATEST_TAG" ]; then if [ -n "$LATEST_TAG" ]; then
echo "Latest matching tag: ${LATEST_TAG}" echo "Latest matching tag: ${LATEST_TAG}"
@@ -263,21 +264,72 @@ jobs:
env: env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
set -euo pipefail
TAG="v${{ needs.compute-version.outputs.version }}-mac" TAG="v${{ needs.compute-version.outputs.version }}-mac"
# Create release
curl -s -X POST \ # Idempotent get-or-create. macOS upload has historically failed
# mid-stream (curl exit 92, exit 28), leaving the release record
# with empty assets. A naive POST /releases on the next run hits
# 409 from Gitea for the duplicate tag, the JSON parse below
# then yields an empty RELEASE_ID, and pipefail aborts with an
# opaque exit 1. Look the release up by tag first; create only
# if it doesn't exist; reuse the existing id otherwise.
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \ "${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \ case "${HTTP_CODE}" in
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json 200)
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*') echo "Release ${TAG} already exists, reusing"
;;
404)
echo "Release ${TAG} not found, creating"
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
;;
*)
echo "Unexpected HTTP ${HTTP_CODE} from get-release-by-tag" >&2
cat release.json >&2 || true
exit 1
;;
esac
RELEASE_ID=$(grep -o '"id":[0-9]*' release.json | head -1 | grep -o '[0-9]*' || true)
if [ -z "${RELEASE_ID}" ]; then
echo "Failed to parse release id; response was:" >&2
cat release.json >&2
exit 1
fi
echo "Release ID: ${RELEASE_ID}" echo "Release ID: ${RELEASE_ID}"
# Upload each artifact
# Upload each artifact. If an asset with the same name already
# exists on the release (left over from a partial prior run),
# delete it first so the upload is replace-not-conflict.
# Network hardening: HTTP/1.1 to dodge HTTP/2 stream flakes
# the macOS runner has hit, retries with backoff for transient
# drops, and -f so HTTP errors stop being silently swallowed.
for file in artifacts/*; do for file in artifacts/*; do
[ -f "$file" ] || continue [ -f "$file" ] || continue
filename=$(basename "$file") filename=$(basename "$file")
EXISTING_ID=$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
if [ -n "${EXISTING_ID}" ]; then
echo "Deleting existing asset ${filename} (id ${EXISTING_ID})"
curl -fsS -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
fi
echo "Uploading ${filename}..." echo "Uploading ${filename}..."
curl -s -X POST \ curl -fsS --http1.1 \
--retry 5 --retry-all-errors --retry-delay 5 \
--max-time 600 \
-X POST \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
--data-binary "@${file}" \ --data-binary "@${file}" \

View File

@@ -76,13 +76,13 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades - `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
- `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect - `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect
- `terminal.html` — Self-contained xterm.js web UI embedded via `include_str!()` - `terminal.html` — Self-contained xterm.js web UI embedded via `include_str!()`
- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `OpenAiCompatibleConfig`, `ContainerInfo`, `AppSettings`, `WebTerminalSettings`). These define the IPC contract with the frontend. - **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `OpenAiCompatibleConfig`, `ClaudeCodeSettings`, `ContainerInfo`, `AppSettings`, `WebTerminalSettings`). These define the IPC contract with the frontend.
- **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs` - **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs`
### Container (`container/`) ### Container (`container/`)
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed - **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, then `sleep infinity` - **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations - **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
### Container Lifecycle ### Container Lifecycle

View File

@@ -253,7 +253,7 @@ When **disabled** (default), Claude prompts you for approval before executing ea
Click **Edit** to open the environment variables modal. Add key-value pairs that will be injected into the container. Per-project variables override global variables with the same key. Click **Edit** to open the environment variables modal. Add key-value pairs that will be injected into the container. Per-project variables override global variables with the same key.
> Reserved prefixes (`ANTHROPIC_`, `AWS_`, `GIT_`, `HOST_`, `CLAUDE_`, `TRIPLE_C_`) are filtered out to prevent conflicts with internal variables. > Reserved prefixes (`ANTHROPIC_`, `AWS_`, `GIT_`, `HOST_`, `TRIPLE_C_`) and specific internal variables (`CLAUDE_INSTRUCTIONS`, `MCP_SERVERS_JSON`, etc.) are filtered out to prevent conflicts. `CLAUDE_CODE_*` variables are now allowed, so you can set Claude Code feature flags directly (e.g., `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1`).
### Port Mappings ### Port Mappings
@@ -268,6 +268,25 @@ Each mapping specifies:
Click **Edit** to write per-project instructions for Claude Code. These are written to `~/.claude/CLAUDE.md` inside the container and provide project-specific context. If you also have global instructions (in Settings), the global instructions come first, followed by the per-project instructions. Click **Edit** to write per-project instructions for Claude Code. These are written to `~/.claude/CLAUDE.md` inside the container and provide project-specific context. If you also have global instructions (in Settings), the global instructions come first, followed by the per-project instructions.
### Claude Code Settings
Click **Edit** next to "Claude Code Settings" to configure Claude Code CLI behavior for this project. These settings control how Claude Code operates inside the container:
| Setting | What It Does |
|---------|-------------|
| **TUI Mode** | Set to **Fullscreen** for flicker-free alt-screen rendering (uses `CLAUDE_CODE_NO_FLICKER=1`) |
| **Effort Level** | Controls reasoning depth: **Low** (fast, less thorough), **Medium**, **High** (deep reasoning) |
| **Focus Mode** | Collapses tool output to one-line summaries, showing only the prompt and final response |
| **Thinking Summaries** | Shows Claude's thinking process as summaries during responses |
| **Session Recap** | Provides context when returning to a session after being away |
| **Auto-Scroll Disabled** | Disables auto-scroll when in fullscreen TUI mode |
| **Env Scrub** | Strips credentials from subprocess environments for security |
| **Prompt Caching (1h)** | Enables 1-hour prompt cache TTL instead of the default 5 minutes |
Per-project settings override global defaults set in Settings. If all settings are at their defaults, no configuration is injected.
> These settings map to Claude Code environment variables and `~/.claude/settings.json` entries. Changes require stopping and restarting the container to take effect.
--- ---
## MCP Servers (Beta) ## MCP Servers (Beta)
@@ -481,6 +500,18 @@ Instructions applied to **all** projects. Written to `~/.claude/CLAUDE.md` in ev
Environment variables applied to **all** project containers. Per-project variables with the same key take precedence. Environment variables applied to **all** project containers. Per-project variables with the same key take precedence.
### Default SSH Key Directory
Path to your SSH key directory (typically `~/.ssh`). This is mounted into **all** containers that don't have a per-project SSH path set. Per-project SSH paths take precedence.
### Default Git Name / Email
Sets `git user.name` and `git user.email` inside all containers. Per-project Git Name / Email settings take precedence. This is useful so you don't have to set the same name and email on every project.
### Claude Code Settings (Global Defaults)
Default Claude Code CLI settings applied to all projects. See [Claude Code Settings](#claude-code-settings) in the Project Configuration section for a description of each setting. Per-project settings override these global defaults.
### Web Terminal ### Web Terminal
Enable remote access to your project terminals from any device on the local network (tablets, phones, other computers). Enable remote access to your project terminals from any device on the local network (tablets, phones, other computers).
@@ -543,7 +574,7 @@ The web terminal UI mirrors the desktop app's terminal experience:
### Multiple Sessions ### Multiple Sessions
You can open multiple terminal sessions (even for the same project). Each session gets its own tab in the top bar. Click a tab to switch, or click the **x** on a tab to close it. Tabs show the project name, with a "(bash)" suffix for shell sessions. You can open multiple terminal sessions (even for the same project). Each session gets its own tab in the top bar. Click a tab to switch, or click the **x** on a tab to close it. Tabs show the project name (or custom session name if provided), with a "(bash)" suffix for shell sessions.
### Bash Shell Sessions ### Bash Shell Sessions
@@ -668,6 +699,24 @@ You can install additional tools at runtime with `sudo apt install`, `pip instal
--- ---
## Claude Code Tips
These features are built into Claude Code and work inside Triple-C containers with no extra configuration:
| Feature | How to Use |
|---------|-----------|
| **Focus Mode** | Run `/focus` or press `Ctrl+O` in the terminal to toggle collapsed tool output |
| **Session Recap** | Run `/recap` to get a summary of what happened in the current session |
| **Session Color** | Run `/color red` (or any color) to color-code your terminal prompt bar |
| **Recurring Tasks** | Run `/loop 5m check the deploy` to repeat a prompt every 5 minutes |
| **Interactive Lessons** | Run `/powerup` to learn Claude Code features with animated demos |
| **Team Onboarding** | Run `/team-onboarding` to generate a teammate ramp-up guide |
| **Bedrock Setup** | Select "3rd-party platform" on the login screen for an interactive Bedrock setup wizard |
| **Vertex AI Setup** | Select "3rd-party platform" on the login screen for an interactive Vertex AI setup wizard |
| **MCP Elicitation** | MCP servers can now request structured user input mid-task — works automatically |
---
## Troubleshooting ## Troubleshooting
### Docker is "Not Available" ### Docker is "Not Available"

View File

@@ -27,7 +27,7 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
### Container Lifecycle ### Container Lifecycle
1. **Create**: New container created with bind mounts, env vars, and labels 1. **Create**: New container created with bind mounts, env vars, and labels
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, sets up MCP servers 2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, sets up MCP servers, injects Claude Code settings
3. **Terminal**: `docker exec` launches Claude Code (or bash shell) with a PTY 3. **Terminal**: `docker exec` launches Claude Code (or bash shell) with a PTY
4. **Stop**: Container halted (filesystem persists in named volume); MCP containers stopped 4. **Stop**: Container halted (filesystem persists in named volume); MCP containers stopped
5. **Restart**: Existing container restarted; recreated if settings changed (detected via SHA-256 fingerprint) 5. **Restart**: Existing container restarted; recreated if settings changed (detected via SHA-256 fingerprint)
@@ -97,6 +97,19 @@ Triple-C includes an optional web terminal server for accessing project terminal
The web terminal shares the existing `ExecSessionManager` via `Arc`-wrapped stores — same Docker exec sessions, different transport (WebSocket instead of Tauri IPC events). The web terminal shares the existing `ExecSessionManager` via `Arc`-wrapped stores — same Docker exec sessions, different transport (WebSocket instead of Tauri IPC events).
### Speech-to-Text (Voice Mode)
Triple-C includes optional speech-to-text powered by [Faster Whisper](https://github.com/SYSTRAN/faster-whisper) running in a separate Docker container. When enabled, a microphone button appears in the bottom-left corner of each terminal view.
- **Hotkey**: `Ctrl+Shift+M` to toggle recording
- **Models**: `tiny`, `small`, or `medium` (configurable in Settings)
- **Port**: Default `9876` (configurable)
- **Language**: Optional language hint for transcription
- **Auto-start**: When STT is enabled in Settings, the container starts automatically with the app — no need to manually start it after each restart
- **On-demand fallback**: If not auto-started, the container starts automatically when you first click the mic button
**How it works**: Audio is captured in the browser via the Web Audio API, encoded as WAV, and sent to the Faster Whisper container's `/transcribe` endpoint. The transcribed text is inserted directly into the active terminal. The STT container uses a named Docker volume (`triple-c-stt-model-cache`) to cache Whisper models across restarts.
### Docker Socket Path ### Docker Socket Path
The socket path is OS-aware: The socket path is OS-aware:
@@ -115,6 +128,7 @@ Users can override this in Settings via the global `docker_socket_path` option.
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) | | `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) |
| `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts | | `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts |
| `app/src/components/projects/ProjectCard.tsx` | Project config, backend selector, action buttons | | `app/src/components/projects/ProjectCard.tsx` | Project config, backend selector, action buttons |
| `app/src/components/projects/ClaudeCodeSettingsModal.tsx` | Claude Code CLI settings modal (TUI mode, effort, focus, caching) |
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar | | `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
| `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) | | `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) |
| `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress | | `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress |
@@ -122,12 +136,14 @@ Users can override this in Settings via the global `docker_socket_path` option.
| `app/src/components/mcp/McpServerCard.tsx` | Individual MCP server configuration card | | `app/src/components/mcp/McpServerCard.tsx` | Individual MCP server configuration card |
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, web terminal, and global settings | | `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, web terminal, and global settings |
| `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management | | `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management |
| `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, container controls) |
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, image paste | | `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, image paste |
| `app/src/components/terminal/SttButton.tsx` | Mic button overlay with on-demand container start |
| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions (claude + bash) | | `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions (claude + bash) |
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) | | `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) | | `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
| `app/src/hooks/useMcpServers.ts` | MCP server CRUD operations | | `app/src/hooks/useMcpServers.ts` | MCP server CRUD operations |
| `app/src/hooks/useVoice.ts` | Voice mode audio capture (currently hidden) | | `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, MCP injection, fingerprinting | | `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, MCP injection, fingerprinting |
| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions, file upload/download via tar | | `app/src-tauri/src/docker/exec.rs` | PTY exec sessions, file upload/download via tar |
| `app/src-tauri/src/docker/image.rs` | Image building/pulling | | `app/src-tauri/src/docker/image.rs` | Image building/pulling |
@@ -135,16 +151,21 @@ Users can override this in Settings via the global `docker_socket_path` option.
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers | | `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) | | `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
| `app/src-tauri/src/commands/mcp_commands.rs` | MCP server CRUD Tauri commands | | `app/src-tauri/src/commands/mcp_commands.rs` | MCP server CRUD Tauri commands |
| `app/src-tauri/src/models/project.rs` | Project struct (backend, Docker access, MCP servers, Mission Control) | | `app/src-tauri/src/models/project.rs` | Project struct (backend, Docker access, Claude Code settings, MCP servers, Mission Control) |
| `app/src-tauri/src/models/mcp_server.rs` | MCP server struct (transport, Docker image, env vars) | | `app/src-tauri/src/models/mcp_server.rs` | MCP server struct (transport, Docker image, env vars) |
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, web terminal) | | `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, Claude Code settings, web terminal, STT) |
| `app/src-tauri/src/web_terminal/server.rs` | Axum HTTP+WS server for remote terminal access | | `app/src-tauri/src/web_terminal/server.rs` | Axum HTTP+WS server for remote terminal access |
| `app/src-tauri/src/web_terminal/ws_handler.rs` | WebSocket connection handler and session management | | `app/src-tauri/src/web_terminal/ws_handler.rs` | WebSocket connection handler and session management |
| `app/src-tauri/src/web_terminal/terminal.html` | Embedded web UI (xterm.js, project picker, tabs) | | `app/src-tauri/src/web_terminal/terminal.html` | Embedded web UI (xterm.js, project picker, tabs) |
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands | | `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
| `app/src-tauri/src/storage/mcp_store.rs` | MCP server persistence (JSON with atomic writes) | | `app/src-tauri/src/storage/mcp_store.rs` | MCP server persistence (JSON with atomic writes) |
| `app/src-tauri/src/docker/stt.rs` | STT Docker container lifecycle (create, start, stop, build, pull) |
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims | | `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims |
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, MCP injection, Mission Control setup | | `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, MCP injection, Claude Code settings injection, Mission Control setup |
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) | | `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode | | `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |

View File

@@ -1 +1 @@
0.2 0.3

104
app/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "triple-c", "name": "triple-c",
"version": "0.2.0", "version": "0.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "triple-c", "name": "triple-c",
"version": "0.2.0", "version": "0.3.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-dialog": "^2.7.0",
@@ -1757,9 +1757,9 @@
} }
}, },
"node_modules/@tauri-apps/api": { "node_modules/@tauri-apps/api": {
"version": "2.10.1", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -1767,9 +1767,9 @@
} }
}, },
"node_modules/@tauri-apps/cli": { "node_modules/@tauri-apps/cli": {
"version": "2.10.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.0.tgz",
"integrity": "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==", "integrity": "sha512-W5Wbuqsb2pHFPTj4TaRNKTj5rwXhDShPiLSY9T18y4ouSR/NNCptAEFxFsBtyNRgL6Vs1a/q9LzfqqYzEwC+Jw==",
"dev": true, "dev": true,
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"bin": { "bin": {
@@ -1783,23 +1783,23 @@
"url": "https://opencollective.com/tauri" "url": "https://opencollective.com/tauri"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.10.0", "@tauri-apps/cli-darwin-arm64": "2.11.0",
"@tauri-apps/cli-darwin-x64": "2.10.0", "@tauri-apps/cli-darwin-x64": "2.11.0",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.0",
"@tauri-apps/cli-linux-arm64-gnu": "2.10.0", "@tauri-apps/cli-linux-arm64-gnu": "2.11.0",
"@tauri-apps/cli-linux-arm64-musl": "2.10.0", "@tauri-apps/cli-linux-arm64-musl": "2.11.0",
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.0", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.0",
"@tauri-apps/cli-linux-x64-gnu": "2.10.0", "@tauri-apps/cli-linux-x64-gnu": "2.11.0",
"@tauri-apps/cli-linux-x64-musl": "2.10.0", "@tauri-apps/cli-linux-x64-musl": "2.11.0",
"@tauri-apps/cli-win32-arm64-msvc": "2.10.0", "@tauri-apps/cli-win32-arm64-msvc": "2.11.0",
"@tauri-apps/cli-win32-ia32-msvc": "2.10.0", "@tauri-apps/cli-win32-ia32-msvc": "2.11.0",
"@tauri-apps/cli-win32-x64-msvc": "2.10.0" "@tauri-apps/cli-win32-x64-msvc": "2.11.0"
} }
}, },
"node_modules/@tauri-apps/cli-darwin-arm64": { "node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.10.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.0.tgz",
"integrity": "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==", "integrity": "sha512-UfMeDNlgIP252rm/KSTuu8yHatPua5TjtUEUf+jyIzVwBNcIl7Ywkdpfj+e5jVVg3EfCTp+4gwuL1dNpgF8clg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1814,9 +1814,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-darwin-x64": { "node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.10.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.0.tgz",
"integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==", "integrity": "sha512-lY1+aPlgyMN7vgjtCdQ3+WODfZkebAcxnrCrO0HjqDpKSXieDkrJbimqeaoM4RwhTSrCLRHfVYiYrfE5E131tg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1831,9 +1831,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.10.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.0.tgz",
"integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==", "integrity": "sha512-5uCP0AusgN3NrKC8EpkuJwjek1k8pEffBdugJSpXPey/QGbPEb8vZ542n/giJ2mZPjMSllDkdhG2QIDpBY4PpQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1848,9 +1848,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm64-gnu": { "node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.10.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.0.tgz",
"integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==", "integrity": "sha512-loDPqtRHMSbIcrH2VBd4GgHoQlF7jJnrZj7MxA2lj1cixS/jEgMAPFqj83U6Wvjete4HfYplbE/gCpSFifA9jw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1865,9 +1865,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm64-musl": { "node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.10.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.0.tgz",
"integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==", "integrity": "sha512-DtSE8ZBlB9H+L+eHkfZ3myt00EVEyAB3e41juEHoE2qT88fgVlJvyrwa9SZYc/xTwCS9TnmK+R84tpg+ZsAg7Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1882,9 +1882,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": { "node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.10.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.0.tgz",
"integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==", "integrity": "sha512-5QdgS4LD+kntClI1aj2JmwjW38LosNXxwCe8viIHEwqYIWuMPdNEIau6/cLogI38Yzx9DnfCPRfEWLyI+5li8Q==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1899,9 +1899,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-x64-gnu": { "node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.10.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.0.tgz",
"integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==", "integrity": "sha512-5UynPXo3Zq9khjVdAbD+YogeLltdVUeOah2ioSIM3tu6H7wY9vMy6rgGJhv9r5R8ZXmk9GttMippdqYJWrnLnA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1916,9 +1916,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-x64-musl": { "node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.10.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.0.tgz",
"integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==", "integrity": "sha512-CNz7fHbApz1Zyhhq73jtGn9JqgNEV/lIWnTnUo6h6ujw+mHsTmkLszvJSM8W6JBaDjNpTTFr/RSNoVL5FMwcTg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1933,9 +1933,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-arm64-msvc": { "node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.10.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.0.tgz",
"integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==", "integrity": "sha512-K+br+VXZ+Xx0n/9FdWohpW5Ugq+2FQUpJScqcPl1hTxXfh3fgjYgt4qA2NgrjlJo+zZPNrmUMl+NLvm0ufEqBQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1950,9 +1950,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-ia32-msvc": { "node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.10.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.0.tgz",
"integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==", "integrity": "sha512-OFV+s3MLZnd75zl0ZAFU5riMpGK4waUEA8ZDuijDsnkU0btz/gHhqh5jVlOn8thyvgdtT3Xyoxqo099MMifH3g==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1967,9 +1967,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-x64-msvc": { "node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.10.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.0.tgz",
"integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==", "integrity": "sha512-AeDTWBd2cOZ6TX133BWsoo+LutG9o0JRcgjMsIfLE13ZugpgCMv/2dJbUiBGeRvbPOGin5A3aYmsArPVV6ZSHQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],

View File

@@ -1,7 +1,7 @@
{ {
"name": "triple-c", "name": "triple-c",
"private": true, "private": true,
"version": "0.2.0", "version": "0.3.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

456
app/src-tauri/Cargo.lock generated
View File

@@ -280,6 +280,21 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@@ -617,9 +632,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]] [[package]]
name = "core-graphics" name = "core-graphics"
version = "0.24.0" version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"core-foundation 0.10.1", "core-foundation 0.10.1",
@@ -699,6 +714,19 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "cssparser"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.13.1",
"smallvec",
]
[[package]] [[package]]
name = "cssparser-macros" name = "cssparser-macros"
version = "0.6.1" version = "0.6.1"
@@ -711,14 +739,20 @@ dependencies = [
[[package]] [[package]]
name = "ctor" name = "ctor"
version = "0.2.9" version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98"
dependencies = [ dependencies = [
"quote", "ctor-proc-macro",
"syn 2.0.117", "dtor",
] ]
[[package]]
name = "ctor-proc-macro"
version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.20.11" version = "0.20.11"
@@ -795,6 +829,17 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "dbus"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73"
dependencies = [
"libc",
"libdbus-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@@ -849,6 +894,27 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.117",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -880,12 +946,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "dispatch"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
[[package]] [[package]]
name = "dispatch2" name = "dispatch2"
version = "0.3.1" version = "0.3.1"
@@ -932,6 +992,21 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "dom_query"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
dependencies = [
"bit-set",
"cssparser 0.36.0",
"foldhash 0.2.0",
"html5ever 0.38.0",
"precomputed-hash",
"selectors 0.36.1",
"tendril 0.5.0",
]
[[package]] [[package]]
name = "dpi" name = "dpi"
version = "0.1.2" version = "0.1.2"
@@ -956,6 +1031,21 @@ dependencies = [
"dtoa", "dtoa",
] ]
[[package]]
name = "dtor"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4"
dependencies = [
"dtor-proc-macro",
]
[[package]]
name = "dtor-proc-macro"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
[[package]] [[package]]
name = "dunce" name = "dunce"
version = "1.0.5" version = "1.0.5"
@@ -1143,6 +1233,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@@ -1614,7 +1710,7 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [ dependencies = [
"foldhash", "foldhash 0.1.5",
] ]
[[package]] [[package]]
@@ -1655,10 +1751,20 @@ checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
dependencies = [ dependencies = [
"log", "log",
"mac", "mac",
"markup5ever", "markup5ever 0.14.1",
"match_token", "match_token",
] ]
[[package]]
name = "html5ever"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2"
dependencies = [
"log",
"markup5ever 0.38.0",
]
[[package]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.0"
@@ -2158,18 +2264,12 @@ version = "0.8.8-speedreader"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2"
dependencies = [ dependencies = [
"cssparser", "cssparser 0.29.6",
"html5ever", "html5ever 0.29.1",
"indexmap 2.13.0", "indexmap 2.13.0",
"selectors", "selectors 0.24.0",
] ]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "leb128fmt" name = "leb128fmt"
version = "0.1.0" version = "0.1.0"
@@ -2206,6 +2306,15 @@ version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "libdbus-sys"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
dependencies = [
"pkg-config",
]
[[package]] [[package]]
name = "libloading" name = "libloading"
version = "0.7.4" version = "0.7.4"
@@ -2296,9 +2405,20 @@ dependencies = [
"log", "log",
"phf 0.11.3", "phf 0.11.3",
"phf_codegen 0.11.3", "phf_codegen 0.11.3",
"string_cache", "string_cache 0.8.9",
"string_cache_codegen", "string_cache_codegen 0.5.4",
"tendril", "tendril 0.4.3",
]
[[package]]
name = "markup5ever"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862"
dependencies = [
"log",
"tendril 0.5.0",
"web_atoms",
] ]
[[package]] [[package]]
@@ -2388,9 +2508,9 @@ dependencies = [
[[package]] [[package]]
name = "muda" name = "muda"
version = "0.17.1" version = "0.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb"
dependencies = [ dependencies = [
"crossbeam-channel", "crossbeam-channel",
"dpi", "dpi",
@@ -2401,10 +2521,10 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation", "objc2-foundation",
"once_cell", "once_cell",
"png 0.17.16", "png 0.18.1",
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -2422,12 +2542,6 @@ dependencies = [
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
[[package]]
name = "ndk-context"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]] [[package]]
name = "ndk-sys" name = "ndk-sys"
version = "0.6.0+11769913" version = "0.6.0+11769913"
@@ -2533,17 +2647,9 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation", "objc2-foundation",
"objc2-quartz-core",
] ]
[[package]] [[package]]
@@ -2563,7 +2669,6 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [ dependencies = [
"bitflags 2.11.0",
"objc2", "objc2",
"objc2-foundation", "objc2-foundation",
] ]
@@ -2602,6 +2707,16 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
] ]
[[package]]
name = "objc2-core-location"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]] [[package]]
name = "objc2-core-text" name = "objc2-core-text"
version = "0.3.2" version = "0.3.2"
@@ -2614,19 +2729,6 @@ dependencies = [
"objc2-core-graphics", "objc2-core-graphics",
] ]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]] [[package]]
name = "objc2-encode" name = "objc2-encode"
version = "4.1.0" version = "4.1.0"
@@ -2666,16 +2768,6 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-javascript-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586"
dependencies = [
"objc2",
"objc2-core-foundation",
]
[[package]] [[package]]
name = "objc2-quartz-core" name = "objc2-quartz-core"
version = "0.3.2" version = "0.3.2"
@@ -2688,17 +2780,6 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
] ]
[[package]]
name = "objc2-security"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-core-foundation",
]
[[package]] [[package]]
name = "objc2-ui-kit" name = "objc2-ui-kit"
version = "0.3.2" version = "0.3.2"
@@ -2706,8 +2787,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"block2",
"objc2", "objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-location",
"objc2-core-text",
"objc2-foundation",
"objc2-quartz-core",
"objc2-user-notifications",
]
[[package]]
name = "objc2-user-notifications"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
dependencies = [
"objc2",
"objc2-foundation", "objc2-foundation",
] ]
@@ -2723,8 +2823,6 @@ dependencies = [
"objc2-app-kit", "objc2-app-kit",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation", "objc2-foundation",
"objc2-javascript-core",
"objc2-security",
] ]
[[package]] [[package]]
@@ -2857,6 +2955,17 @@ dependencies = [
"phf_shared 0.11.3", "phf_shared 0.11.3",
] ]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros 0.13.1",
"phf_shared 0.13.1",
"serde",
]
[[package]] [[package]]
name = "phf_codegen" name = "phf_codegen"
version = "0.8.0" version = "0.8.0"
@@ -2877,6 +2986,16 @@ dependencies = [
"phf_shared 0.11.3", "phf_shared 0.11.3",
] ]
[[package]]
name = "phf_codegen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
]
[[package]] [[package]]
name = "phf_generator" name = "phf_generator"
version = "0.8.0" version = "0.8.0"
@@ -2907,6 +3026,16 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
] ]
[[package]]
name = "phf_generator"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared 0.13.1",
]
[[package]] [[package]]
name = "phf_macros" name = "phf_macros"
version = "0.10.0" version = "0.10.0"
@@ -2934,6 +3063,19 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "phf_macros"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "phf_shared" name = "phf_shared"
version = "0.8.0" version = "0.8.0"
@@ -2961,6 +3103,15 @@ dependencies = [
"siphasher 1.0.2", "siphasher 1.0.2",
] ]
[[package]]
name = "phf_shared"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher 1.0.2",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.16" version = "0.2.16"
@@ -3751,14 +3902,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"cssparser", "cssparser 0.29.6",
"derive_more", "derive_more 0.99.20",
"fxhash", "fxhash",
"log", "log",
"phf 0.8.0", "phf 0.8.0",
"phf_codegen 0.8.0", "phf_codegen 0.8.0",
"precomputed-hash", "precomputed-hash",
"servo_arc", "servo_arc 0.2.0",
"smallvec",
]
[[package]]
name = "selectors"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [
"bitflags 2.11.0",
"cssparser 0.36.0",
"derive_more 2.1.1",
"log",
"new_debug_unreachable",
"phf 0.13.1",
"phf_codegen 0.13.1",
"precomputed-hash",
"rustc-hash",
"servo_arc 0.4.3",
"smallvec", "smallvec",
] ]
@@ -3953,6 +4123,15 @@ dependencies = [
"stable_deref_trait", "stable_deref_trait",
] ]
[[package]]
name = "servo_arc"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
dependencies = [
"stable_deref_trait",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.6" version = "0.10.6"
@@ -4098,6 +4277,18 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "string_cache"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.13.1",
"precomputed-hash",
]
[[package]] [[package]]
name = "string_cache_codegen" name = "string_cache_codegen"
version = "0.5.4" version = "0.5.4"
@@ -4110,6 +4301,18 @@ dependencies = [
"quote", "quote",
] ]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"proc-macro2",
"quote",
]
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
@@ -4190,35 +4393,35 @@ dependencies = [
[[package]] [[package]]
name = "tao" name = "tao"
version = "0.34.5" version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" checksum = "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"block2", "block2",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
"dispatch", "dbus",
"dispatch2",
"dlopen2", "dlopen2",
"dpi", "dpi",
"gdkwayland-sys", "gdkwayland-sys",
"gdkx11-sys", "gdkx11-sys",
"gtk", "gtk",
"jni", "jni",
"lazy_static",
"libc", "libc",
"log", "log",
"ndk", "ndk",
"ndk-context",
"ndk-sys", "ndk-sys",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
"objc2-foundation", "objc2-foundation",
"objc2-ui-kit",
"once_cell", "once_cell",
"parking_lot", "parking_lot",
"percent-encoding",
"raw-window-handle", "raw-window-handle",
"scopeguard",
"tao-macros", "tao-macros",
"unicode-segmentation", "unicode-segmentation",
"url", "url",
@@ -4258,9 +4461,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]] [[package]]
name = "tauri" name = "tauri"
version = "2.10.2" version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "463ae8677aa6d0f063a900b9c41ecd4ac2b7ca82f0b058cc4491540e55b20129" checksum = "d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -4310,9 +4513,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-build" name = "tauri-build"
version = "2.5.5" version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74" checksum = "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cargo_toml", "cargo_toml",
@@ -4326,15 +4529,14 @@ dependencies = [
"serde_json", "serde_json",
"tauri-utils", "tauri-utils",
"tauri-winres", "tauri-winres",
"toml 0.9.12+spec-1.1.0",
"walkdir", "walkdir",
] ]
[[package]] [[package]]
name = "tauri-codegen" name = "tauri-codegen"
version = "2.5.4" version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aac423e5859d9f9ccdd32e3cf6a5866a15bedbf25aa6630bcb2acde9468f6ae3" checksum = "d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"brotli", "brotli",
@@ -4359,9 +4561,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-macros" name = "tauri-macros"
version = "2.5.4" version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b6a1bd2861ff0c8766b1d38b32a6a410f6dc6532d4ef534c47cfb2236092f59" checksum = "bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
@@ -4470,9 +4672,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.10.0" version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b885ffeac82b00f1f6fd292b6e5aabfa7435d537cef57d11e38a489956535651" checksum = "1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95"
dependencies = [ dependencies = [
"cookie", "cookie",
"dpi", "dpi",
@@ -4495,9 +4697,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-runtime-wry" name = "tauri-runtime-wry"
version = "2.10.0" version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5204682391625e867d16584fedc83fc292fb998814c9f7918605c789cd876314" checksum = "2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
@@ -4505,7 +4707,6 @@ dependencies = [
"log", "log",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
"objc2-foundation",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"raw-window-handle", "raw-window-handle",
@@ -4522,17 +4723,18 @@ dependencies = [
[[package]] [[package]]
name = "tauri-utils" name = "tauri-utils"
version = "2.8.2" version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e" checksum = "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"brotli", "brotli",
"cargo_metadata", "cargo_metadata",
"ctor", "ctor",
"dom_query",
"dunce", "dunce",
"glob", "glob",
"html5ever", "html5ever 0.29.1",
"http", "http",
"infer", "infer",
"json-patch", "json-patch",
@@ -4540,6 +4742,7 @@ dependencies = [
"log", "log",
"memchr", "memchr",
"phf 0.11.3", "phf 0.11.3",
"plist",
"proc-macro2", "proc-macro2",
"quote", "quote",
"regex", "regex",
@@ -4593,6 +4796,16 @@ dependencies = [
"utf-8", "utf-8",
] ]
[[package]]
name = "tendril"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24"
dependencies = [
"new_debug_unreachable",
"utf-8",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -4928,9 +5141,9 @@ dependencies = [
[[package]] [[package]]
name = "tray-icon" name = "tray-icon"
version = "0.21.3" version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773"
dependencies = [ dependencies = [
"crossbeam-channel", "crossbeam-channel",
"dirs", "dirs",
@@ -4942,15 +5155,15 @@ dependencies = [
"objc2-core-graphics", "objc2-core-graphics",
"objc2-foundation", "objc2-foundation",
"once_cell", "once_cell",
"png 0.17.16", "png 0.18.1",
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
name = "triple-c" name = "triple-c"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
@@ -5353,6 +5566,18 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web_atoms"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [
"phf 0.13.1",
"phf_codegen 0.13.1",
"string_cache 0.9.0",
"string_cache_codegen 0.6.1",
]
[[package]] [[package]]
name = "webkit2gtk" name = "webkit2gtk"
version = "2.0.2" version = "2.0.2"
@@ -6000,24 +6225,23 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]] [[package]]
name = "wry" name = "wry"
version = "0.54.2" version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb26159b420aa77684589a744ae9a9461a95395b848764ad12290a14d960a11a" checksum = "3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"block2", "block2",
"cookie", "cookie",
"crossbeam-channel", "crossbeam-channel",
"dirs", "dirs",
"dom_query",
"dpi", "dpi",
"dunce", "dunce",
"gdkx11", "gdkx11",
"gtk", "gtk",
"html5ever",
"http", "http",
"javascriptcore-rs", "javascriptcore-rs",
"jni", "jni",
"kuchikiki",
"libc", "libc",
"ndk", "ndk",
"objc2", "objc2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "triple-c" name = "triple-c"
version = "0.2.0" version = "0.3.0"
edition = "2021" edition = "2021"
[lib] [lib]

File diff suppressed because one or more lines are too long

View File

@@ -351,10 +351,10 @@
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
}, },
{ {
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`",
"type": "string", "type": "string",
"const": "core:app:default", "const": "core:app:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`"
}, },
{ {
"description": "Enables the app_hide command without any pre-configured scope.", "description": "Enables the app_hide command without any pre-configured scope.",
@@ -428,6 +428,12 @@
"const": "core:app:allow-set-dock-visibility", "const": "core:app:allow-set-dock-visibility",
"markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope."
}, },
{
"description": "Enables the supports_multiple_windows command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-supports-multiple-windows",
"markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope."
},
{ {
"description": "Enables the tauri_version command without any pre-configured scope.", "description": "Enables the tauri_version command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -512,6 +518,12 @@
"const": "core:app:deny-set-dock-visibility", "const": "core:app:deny-set-dock-visibility",
"markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope."
}, },
{
"description": "Denies the supports_multiple_windows command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-supports-multiple-windows",
"markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope."
},
{ {
"description": "Denies the tauri_version command without any pre-configured scope.", "description": "Denies the tauri_version command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -1035,10 +1047,10 @@
"markdownDescription": "Denies the close command without any pre-configured scope." "markdownDescription": "Denies the close command without any pre-configured scope."
}, },
{ {
"description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`",
"type": "string", "type": "string",
"const": "core:tray:default", "const": "core:tray:default",
"markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`"
}, },
{ {
"description": "Enables the get_by_id command without any pre-configured scope.", "description": "Enables the get_by_id command without any pre-configured scope.",
@@ -1070,6 +1082,12 @@
"const": "core:tray:allow-set-icon-as-template", "const": "core:tray:allow-set-icon-as-template",
"markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope."
}, },
{
"description": "Enables the set_icon_with_as_template command without any pre-configured scope.",
"type": "string",
"const": "core:tray:allow-set-icon-with-as-template",
"markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope."
},
{ {
"description": "Enables the set_menu command without any pre-configured scope.", "description": "Enables the set_menu command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -1136,6 +1154,12 @@
"const": "core:tray:deny-set-icon-as-template", "const": "core:tray:deny-set-icon-as-template",
"markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope."
}, },
{
"description": "Denies the set_icon_with_as_template command without any pre-configured scope.",
"type": "string",
"const": "core:tray:deny-set-icon-with-as-template",
"markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope."
},
{ {
"description": "Denies the set_menu command without any pre-configured scope.", "description": "Denies the set_menu command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -1395,10 +1419,16 @@
"markdownDescription": "Denies the webview_size command without any pre-configured scope." "markdownDescription": "Denies the webview_size command without any pre-configured scope."
}, },
{ {
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`",
"type": "string", "type": "string",
"const": "core:window:default", "const": "core:window:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`"
},
{
"description": "Enables the activity_name command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-activity-name",
"markdownDescription": "Enables the activity_name command without any pre-configured scope."
}, },
{ {
"description": "Enables the available_monitors command without any pre-configured scope.", "description": "Enables the available_monitors command without any pre-configured scope.",
@@ -1592,6 +1622,12 @@
"const": "core:window:allow-scale-factor", "const": "core:window:allow-scale-factor",
"markdownDescription": "Enables the scale_factor command without any pre-configured scope." "markdownDescription": "Enables the scale_factor command without any pre-configured scope."
}, },
{
"description": "Enables the scene_identifier command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-scene-identifier",
"markdownDescription": "Enables the scene_identifier command without any pre-configured scope."
},
{ {
"description": "Enables the set_always_on_bottom command without any pre-configured scope.", "description": "Enables the set_always_on_bottom command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -1856,6 +1892,12 @@
"const": "core:window:allow-unminimize", "const": "core:window:allow-unminimize",
"markdownDescription": "Enables the unminimize command without any pre-configured scope." "markdownDescription": "Enables the unminimize command without any pre-configured scope."
}, },
{
"description": "Denies the activity_name command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-activity-name",
"markdownDescription": "Denies the activity_name command without any pre-configured scope."
},
{ {
"description": "Denies the available_monitors command without any pre-configured scope.", "description": "Denies the available_monitors command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2048,6 +2090,12 @@
"const": "core:window:deny-scale-factor", "const": "core:window:deny-scale-factor",
"markdownDescription": "Denies the scale_factor command without any pre-configured scope." "markdownDescription": "Denies the scale_factor command without any pre-configured scope."
}, },
{
"description": "Denies the scene_identifier command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-scene-identifier",
"markdownDescription": "Denies the scene_identifier command without any pre-configured scope."
},
{ {
"description": "Denies the set_always_on_bottom command without any pre-configured scope.", "description": "Denies the set_always_on_bottom command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2313,22 +2361,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
}, },
{ {
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`", "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string", "type": "string",
"const": "dialog:default", "const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`" "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
}, },
{ {
"description": "Enables the ask command without any pre-configured scope.", "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string", "type": "string",
"const": "dialog:allow-ask", "const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope." "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
}, },
{ {
"description": "Enables the confirm command without any pre-configured scope.", "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string", "type": "string",
"const": "dialog:allow-confirm", "const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope." "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
}, },
{ {
"description": "Enables the message command without any pre-configured scope.", "description": "Enables the message command without any pre-configured scope.",
@@ -2349,16 +2397,16 @@
"markdownDescription": "Enables the save command without any pre-configured scope." "markdownDescription": "Enables the save command without any pre-configured scope."
}, },
{ {
"description": "Denies the ask command without any pre-configured scope.", "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string", "type": "string",
"const": "dialog:deny-ask", "const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope." "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
}, },
{ {
"description": "Denies the confirm command without any pre-configured scope.", "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string", "type": "string",
"const": "dialog:deny-confirm", "const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope." "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
}, },
{ {
"description": "Denies the message command without any pre-configured scope.", "description": "Denies the message command without any pre-configured scope.",

View File

@@ -351,10 +351,10 @@
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
}, },
{ {
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`",
"type": "string", "type": "string",
"const": "core:app:default", "const": "core:app:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`"
}, },
{ {
"description": "Enables the app_hide command without any pre-configured scope.", "description": "Enables the app_hide command without any pre-configured scope.",
@@ -428,6 +428,12 @@
"const": "core:app:allow-set-dock-visibility", "const": "core:app:allow-set-dock-visibility",
"markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope."
}, },
{
"description": "Enables the supports_multiple_windows command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-supports-multiple-windows",
"markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope."
},
{ {
"description": "Enables the tauri_version command without any pre-configured scope.", "description": "Enables the tauri_version command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -512,6 +518,12 @@
"const": "core:app:deny-set-dock-visibility", "const": "core:app:deny-set-dock-visibility",
"markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope."
}, },
{
"description": "Denies the supports_multiple_windows command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-supports-multiple-windows",
"markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope."
},
{ {
"description": "Denies the tauri_version command without any pre-configured scope.", "description": "Denies the tauri_version command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -1035,10 +1047,10 @@
"markdownDescription": "Denies the close command without any pre-configured scope." "markdownDescription": "Denies the close command without any pre-configured scope."
}, },
{ {
"description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`",
"type": "string", "type": "string",
"const": "core:tray:default", "const": "core:tray:default",
"markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`"
}, },
{ {
"description": "Enables the get_by_id command without any pre-configured scope.", "description": "Enables the get_by_id command without any pre-configured scope.",
@@ -1070,6 +1082,12 @@
"const": "core:tray:allow-set-icon-as-template", "const": "core:tray:allow-set-icon-as-template",
"markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope."
}, },
{
"description": "Enables the set_icon_with_as_template command without any pre-configured scope.",
"type": "string",
"const": "core:tray:allow-set-icon-with-as-template",
"markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope."
},
{ {
"description": "Enables the set_menu command without any pre-configured scope.", "description": "Enables the set_menu command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -1136,6 +1154,12 @@
"const": "core:tray:deny-set-icon-as-template", "const": "core:tray:deny-set-icon-as-template",
"markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope."
}, },
{
"description": "Denies the set_icon_with_as_template command without any pre-configured scope.",
"type": "string",
"const": "core:tray:deny-set-icon-with-as-template",
"markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope."
},
{ {
"description": "Denies the set_menu command without any pre-configured scope.", "description": "Denies the set_menu command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -1395,10 +1419,16 @@
"markdownDescription": "Denies the webview_size command without any pre-configured scope." "markdownDescription": "Denies the webview_size command without any pre-configured scope."
}, },
{ {
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`",
"type": "string", "type": "string",
"const": "core:window:default", "const": "core:window:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`"
},
{
"description": "Enables the activity_name command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-activity-name",
"markdownDescription": "Enables the activity_name command without any pre-configured scope."
}, },
{ {
"description": "Enables the available_monitors command without any pre-configured scope.", "description": "Enables the available_monitors command without any pre-configured scope.",
@@ -1592,6 +1622,12 @@
"const": "core:window:allow-scale-factor", "const": "core:window:allow-scale-factor",
"markdownDescription": "Enables the scale_factor command without any pre-configured scope." "markdownDescription": "Enables the scale_factor command without any pre-configured scope."
}, },
{
"description": "Enables the scene_identifier command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-scene-identifier",
"markdownDescription": "Enables the scene_identifier command without any pre-configured scope."
},
{ {
"description": "Enables the set_always_on_bottom command without any pre-configured scope.", "description": "Enables the set_always_on_bottom command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -1856,6 +1892,12 @@
"const": "core:window:allow-unminimize", "const": "core:window:allow-unminimize",
"markdownDescription": "Enables the unminimize command without any pre-configured scope." "markdownDescription": "Enables the unminimize command without any pre-configured scope."
}, },
{
"description": "Denies the activity_name command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-activity-name",
"markdownDescription": "Denies the activity_name command without any pre-configured scope."
},
{ {
"description": "Denies the available_monitors command without any pre-configured scope.", "description": "Denies the available_monitors command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2048,6 +2090,12 @@
"const": "core:window:deny-scale-factor", "const": "core:window:deny-scale-factor",
"markdownDescription": "Denies the scale_factor command without any pre-configured scope." "markdownDescription": "Denies the scale_factor command without any pre-configured scope."
}, },
{
"description": "Denies the scene_identifier command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-scene-identifier",
"markdownDescription": "Denies the scene_identifier command without any pre-configured scope."
},
{ {
"description": "Denies the set_always_on_bottom command without any pre-configured scope.", "description": "Denies the set_always_on_bottom command without any pre-configured scope.",
"type": "string", "type": "string",
@@ -2313,22 +2361,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope." "markdownDescription": "Denies the unminimize command without any pre-configured scope."
}, },
{ {
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`", "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string", "type": "string",
"const": "dialog:default", "const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`" "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
}, },
{ {
"description": "Enables the ask command without any pre-configured scope.", "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string", "type": "string",
"const": "dialog:allow-ask", "const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope." "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
}, },
{ {
"description": "Enables the confirm command without any pre-configured scope.", "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string", "type": "string",
"const": "dialog:allow-confirm", "const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope." "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
}, },
{ {
"description": "Enables the message command without any pre-configured scope.", "description": "Enables the message command without any pre-configured scope.",
@@ -2349,16 +2397,16 @@
"markdownDescription": "Enables the save command without any pre-configured scope." "markdownDescription": "Enables the save command without any pre-configured scope."
}, },
{ {
"description": "Denies the ask command without any pre-configured scope.", "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string", "type": "string",
"const": "dialog:deny-ask", "const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope." "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
}, },
{ {
"description": "Denies the confirm command without any pre-configured scope.", "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string", "type": "string",
"const": "dialog:deny-confirm", "const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope." "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
}, },
{ {
"description": "Denies the message command without any pre-configured scope.", "description": "Denies the message command without any pre-configured scope.",

View File

@@ -1,23 +1,58 @@
use tauri::State; use tauri::State;
use crate::models::Project;
use crate::AppState; use crate::AppState;
#[tauri::command] /// Resolve AWS profile: project-level → global settings → "default".
pub async fn aws_sso_refresh( pub fn resolve_profile_for_project(project: &Project, global_profile: Option<&str>) -> String {
project_id: String, project
state: State<'_, AppState>, .bedrock_config
) -> Result<(), String> { .as_ref()
let project = state.projects_store.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let profile = project.bedrock_config.as_ref()
.and_then(|b| b.aws_profile.clone()) .and_then(|b| b.aws_profile.clone())
.or_else(|| state.settings_store.get().global_aws.aws_profile.clone()) .or_else(|| global_profile.map(|s| s.to_string()))
.unwrap_or_else(|| "default".to_string()); .unwrap_or_else(|| "default".to_string())
}
/// Check if the AWS session is valid for the given profile on the host.
/// Returns `Ok(true)` if valid, `Ok(false)` if expired/invalid.
pub async fn check_sso_session(profile: &str) -> Result<bool, String> {
let output = tokio::process::Command::new("aws")
.args(["sts", "get-caller-identity", "--profile", profile])
.output()
.await
.map_err(|e| format!("Failed to run aws sts get-caller-identity: {}", e))?;
Ok(output.status.success())
}
/// Check if the given AWS profile uses SSO (has sso_start_url or sso_session configured).
pub async fn is_sso_profile(profile: &str) -> Result<bool, String> {
let check_start_url = tokio::process::Command::new("aws")
.args(["configure", "get", "sso_start_url", "--profile", profile])
.output()
.await;
if let Ok(out) = check_start_url {
if out.status.success() {
return Ok(true);
}
}
let check_session = tokio::process::Command::new("aws")
.args(["configure", "get", "sso_session", "--profile", profile])
.output()
.await;
if let Ok(out) = check_session {
if out.status.success() {
return Ok(true);
}
}
Ok(false)
}
/// Run `aws sso login --profile X` on the host. This is interactive (opens a browser).
pub async fn run_sso_login(profile: &str) -> Result<(), String> {
log::info!("Running host-side AWS SSO login for profile '{}'", profile); log::info!("Running host-side AWS SSO login for profile '{}'", profile);
let status = tokio::process::Command::new("aws") let status = tokio::process::Command::new("aws")
.args(["sso", "login", "--profile", &profile]) .args(["sso", "login", "--profile", profile])
.status() .status()
.await .await
.map_err(|e| format!("Failed to run aws sso login: {}", e))?; .map_err(|e| format!("Failed to run aws sso login: {}", e))?;
@@ -28,3 +63,19 @@ pub async fn aws_sso_refresh(
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn aws_sso_refresh(
project_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let project = state.projects_store.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let profile = resolve_profile_for_project(
&project,
state.settings_store.get().global_aws.aws_profile.as_deref(),
);
run_sso_login(&profile).await
}

View File

@@ -0,0 +1,11 @@
use crate::install_helper::{self, InstallOptions};
#[tauri::command]
pub async fn detect_install_options() -> Result<InstallOptions, String> {
Ok(install_helper::detect_install_options())
}
#[tauri::command]
pub async fn run_docker_install(app_handle: tauri::AppHandle) -> Result<(), String> {
install_helper::platform::run_install(&app_handle).await
}

View File

@@ -2,6 +2,7 @@ pub mod aws_commands;
pub mod docker_commands; pub mod docker_commands;
pub mod file_commands; pub mod file_commands;
pub mod help_commands; pub mod help_commands;
pub mod install_helper_commands;
pub mod mcp_commands; pub mod mcp_commands;
pub mod project_commands; pub mod project_commands;
pub mod settings_commands; pub mod settings_commands;

View File

@@ -1,7 +1,8 @@
use tauri::{Emitter, State}; use tauri::{Emitter, State};
use crate::commands::aws_commands;
use crate::docker; use crate::docker;
use crate::models::{container_config, Backend, McpServer, Project, ProjectPath, ProjectStatus}; use crate::models::{container_config, Backend, BedrockAuthMethod, McpServer, Project, ProjectPath, ProjectStatus};
use crate::storage::secure; use crate::storage::secure;
use crate::AppState; use crate::AppState;
@@ -192,22 +193,96 @@ pub async fn start_project_container(
if project.backend == Backend::Ollama { if project.backend == Backend::Ollama {
let ollama = project.ollama_config.as_ref() let ollama = project.ollama_config.as_ref()
.ok_or_else(|| "Ollama backend selected but no Ollama configuration found.".to_string())?; .ok_or_else(|| "Ollama backend selected but no Ollama configuration found.".to_string())?;
if ollama.base_url.is_empty() { if ollama.base_url.is_empty()
return Err("Ollama base URL is required.".to_string()); && settings.global_ollama.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
{
return Err("Ollama base URL is required. Set it per-project or in global Ollama settings.".to_string());
} }
} }
if project.backend == Backend::OpenAiCompatible { if project.backend == Backend::OpenAiCompatible {
let oai_config = project.openai_compatible_config.as_ref() let oai_config = project.openai_compatible_config.as_ref()
.ok_or_else(|| "OpenAI Compatible backend selected but no configuration found.".to_string())?; .ok_or_else(|| "OpenAI Compatible backend selected but no configuration found.".to_string())?;
if oai_config.base_url.is_empty() { if oai_config.base_url.is_empty()
return Err("OpenAI Compatible base URL is required.".to_string()); && settings.global_openai_compatible.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
{
return Err("OpenAI Compatible base URL is required. Set it per-project or in global settings.".to_string());
} }
} }
// Update status to starting // Update status to starting
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?; state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
// Pre-validate AWS SSO session on the host for Bedrock Profile projects.
// If the session is expired, trigger `aws sso login` before starting the container
// so the entrypoint copies already-fresh credentials from the host mount.
if project.backend == Backend::Bedrock {
if let Some(ref bedrock) = project.bedrock_config {
if bedrock.auth_method == BedrockAuthMethod::Profile {
let profile = aws_commands::resolve_profile_for_project(
&project,
settings.global_aws.aws_profile.as_deref(),
);
emit_progress(&app_handle, &project_id, "Validating AWS session...");
let session_valid = tokio::time::timeout(
std::time::Duration::from_secs(10),
aws_commands::check_sso_session(&profile),
)
.await;
match session_valid {
Ok(Ok(true)) => {
emit_progress(&app_handle, &project_id, "AWS session valid.");
}
Ok(Ok(false)) => {
// Session expired — check if this is an SSO profile
if aws_commands::is_sso_profile(&profile).await.unwrap_or(false) {
emit_progress(
&app_handle,
&project_id,
"AWS session expired. Starting SSO login (check your browser)...",
);
match aws_commands::run_sso_login(&profile).await {
Ok(()) => {
emit_progress(
&app_handle,
&project_id,
"SSO login successful.",
);
}
Err(e) => {
log::warn!(
"SSO login failed for profile '{}': {} — continuing anyway",
profile,
e
);
emit_progress(
&app_handle,
&project_id,
"SSO login failed or cancelled. Continuing...",
);
}
}
} else {
log::warn!(
"AWS session invalid for profile '{}' (not SSO). Continuing...",
profile
);
}
}
Ok(Err(e)) => {
log::warn!("Failed to check AWS session: {} — continuing anyway", e);
}
Err(_) => {
log::warn!("AWS session check timed out — continuing anyway");
}
}
}
}
}
// Wrap container operations so that any failure resets status to Stopped. // Wrap container operations so that any failure resets status to Stopped.
let result: Result<String, String> = async { let result: Result<String, String> = async {
// Ensure image exists // Ensure image exists
@@ -263,10 +338,17 @@ pub async fn start_project_container(
let needs_recreate = docker::container_needs_recreation( let needs_recreate = docker::container_needs_recreation(
&existing_id, &existing_id,
&project, &project,
&settings.global_aws,
&settings.global_ollama,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(), settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars, &settings.global_custom_env_vars,
settings.timezone.as_deref(), settings.timezone.as_deref(),
&enabled_mcp, &enabled_mcp,
settings.global_claude_code_settings.as_ref(),
settings.default_ssh_key_path.as_deref(),
settings.default_git_user_name.as_deref(),
settings.default_git_user_email.as_deref(),
).await.unwrap_or(false); ).await.unwrap_or(false);
if needs_recreate { if needs_recreate {
@@ -294,11 +376,17 @@ pub async fn start_project_container(
&create_image, &create_image,
aws_config_path.as_deref(), aws_config_path.as_deref(),
&settings.global_aws, &settings.global_aws,
&settings.global_ollama,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(), settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars, &settings.global_custom_env_vars,
settings.timezone.as_deref(), settings.timezone.as_deref(),
&enabled_mcp, &enabled_mcp,
network_name.as_deref(), network_name.as_deref(),
settings.global_claude_code_settings.as_ref(),
settings.default_ssh_key_path.as_deref(),
settings.default_git_user_name.as_deref(),
settings.default_git_user_email.as_deref(),
).await?; ).await?;
emit_progress(&app_handle, &project_id, "Starting container..."); emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&new_id).await?; docker::start_container(&new_id).await?;
@@ -327,11 +415,17 @@ pub async fn start_project_container(
&create_image, &create_image,
aws_config_path.as_deref(), aws_config_path.as_deref(),
&settings.global_aws, &settings.global_aws,
&settings.global_ollama,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(), settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars, &settings.global_custom_env_vars,
settings.timezone.as_deref(), settings.timezone.as_deref(),
&enabled_mcp, &enabled_mcp,
network_name.as_deref(), network_name.as_deref(),
settings.global_claude_code_settings.as_ref(),
settings.default_ssh_key_path.as_deref(),
settings.default_git_user_name.as_deref(),
settings.default_git_user_email.as_deref(),
).await?; ).await?;
emit_progress(&app_handle, &project_id, "Starting container..."); emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&new_id).await?; docker::start_container(&new_id).await?;

View File

@@ -1,5 +1,6 @@
use tauri::{AppHandle, Emitter, State}; use tauri::{AppHandle, Emitter, State};
use crate::commands::aws_commands;
use crate::models::{Backend, BedrockAuthMethod, Project}; use crate::models::{Backend, BedrockAuthMethod, Project};
use crate::AppState; use crate::AppState;
@@ -8,7 +9,7 @@ use crate::AppState;
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates /// For Bedrock Profile projects, wraps `claude` in a bash script that validates
/// the AWS session first. If the SSO session is expired, runs `aws sso login` /// the AWS session first. If the SSO session is expired, runs `aws sso login`
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon). /// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> { fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
let is_bedrock_profile = project.backend == Backend::Bedrock let is_bedrock_profile = project.backend == Backend::Bedrock
&& project && project
.bedrock_config .bedrock_config
@@ -21,23 +22,30 @@ fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
if project.full_permissions { if project.full_permissions {
cmd.push("--dangerously-skip-permissions".to_string()); cmd.push("--dangerously-skip-permissions".to_string());
} }
if let Some(name) = session_name {
if !name.is_empty() {
cmd.push("-n".to_string());
cmd.push(name.to_string());
}
}
return cmd; return cmd;
} }
// Resolve AWS profile: project-level → global settings → "default" let profile = aws_commands::resolve_profile_for_project(
let profile = project project,
.bedrock_config state.settings_store.get().global_aws.aws_profile.as_deref(),
.as_ref() );
.and_then(|b| b.aws_profile.clone())
.or_else(|| state.settings_store.get().global_aws.aws_profile.clone())
.unwrap_or_else(|| "default".to_string());
// Build a bash wrapper that validates credentials, re-auths if needed, // Build a bash wrapper that validates credentials, re-auths if needed,
// then exec's into claude. // then exec's into claude.
let name_flag = session_name
.filter(|n| !n.is_empty())
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
.unwrap_or_default();
let claude_cmd = if project.full_permissions { let claude_cmd = if project.full_permissions {
"exec claude --dangerously-skip-permissions" format!("exec claude --dangerously-skip-permissions{}", name_flag)
} else { } else {
"exec claude" format!("exec claude{}", name_flag)
}; };
let script = format!( let script = format!(
@@ -83,6 +91,7 @@ pub async fn open_terminal_session(
project_id: String, project_id: String,
session_id: String, session_id: String,
session_type: Option<String>, session_type: Option<String>,
session_name: Option<String>,
app_handle: AppHandle, app_handle: AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -98,7 +107,7 @@ pub async fn open_terminal_session(
let cmd = match session_type.as_deref() { let cmd = match session_type.as_deref() {
Some("bash") => vec!["bash".to_string(), "-l".to_string()], Some("bash") => vec!["bash".to_string(), "-l".to_string()],
_ => build_terminal_cmd(&project, &state), _ => build_terminal_cmd(&project, &state, session_name.as_deref()),
}; };
let output_event = format!("terminal-output-{}", session_id); let output_event = format!("terminal-output-{}", session_id);

View File

@@ -8,7 +8,7 @@ use std::collections::HashMap;
use sha2::{Sha256, Digest}; use sha2::{Sha256, Digest};
use super::client::get_docker; use super::client::get_docker;
use crate::models::{Backend, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath}; use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
@@ -88,6 +88,40 @@ This project uses **Flight Control** (bundled with Triple-C) for structured deve
3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored 3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored
4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)"#; 4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)"#;
const SANDBOX_INSTRUCTIONS: &str = r#"## Sandbox Mode
This container has Claude Code's bash sandbox enabled, managed by Triple-C
(toggle it from the project's "Sandbox mode" switch in the Triple-C UI).
Bash commands run inside `bubblewrap` with filesystem and network isolation
(`enableWeakerNestedSandbox` is on because we are inside Docker).
### When a command fails because of sandbox restrictions
Triple-C disables the `dangerouslyDisableSandbox` escape hatch
(`allowUnsandboxedCommands: false`), so failing commands cannot bypass the
sandbox at runtime. To make a blocked command work, edit
`~/.claude/settings.json` and restart Claude Code:
| Need | Setting |
|---|---|
| Write to a path outside the project (e.g. `~/.kube`) | Add to `sandbox.filesystem.allowWrite` |
| Reach a new domain | Will prompt; or add permanently to `sandbox.allowedDomains` |
| Run a specific tool entirely outside the sandbox | Add a glob (e.g. `"docker *"`) to `sandbox.excludedCommands` |
### Docker commands
The `docker` CLI does not work inside the sandbox. If this project has
"Allow container spawning" enabled in Triple-C and you need to run
`docker` commands, add `"docker *"` to `sandbox.excludedCommands` in
`~/.claude/settings.json`. Other tools known to be sandbox-incompatible
include `watchman` — pass `--no-watchman` to `jest`.
### Disabling sandbox mode
Do not change `sandbox.enabled` in `settings.json` — Triple-C overwrites it
on every container start. To turn sandbox off, stop the container in
Triple-C, flip the "Sandbox mode" switch off, then start the container."#;
/// Build the full CLAUDE_INSTRUCTIONS value by merging global + project /// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
/// instructions, appending port mapping docs, and appending scheduler docs. /// instructions, appending port mapping docs, and appending scheduler docs.
/// Used by both create_container() and container_needs_recreation() to ensure /// Used by both create_container() and container_needs_recreation() to ensure
@@ -97,6 +131,7 @@ fn build_claude_instructions(
project_instructions: Option<&str>, project_instructions: Option<&str>,
port_mappings: &[PortMapping], port_mappings: &[PortMapping],
mission_control_enabled: bool, mission_control_enabled: bool,
sandbox_enabled: bool,
) -> Option<String> { ) -> Option<String> {
let mut combined = merge_claude_instructions( let mut combined = merge_claude_instructions(
global_instructions, global_instructions,
@@ -126,20 +161,30 @@ fn build_claude_instructions(
None => SCHEDULER_INSTRUCTIONS.to_string(), None => SCHEDULER_INSTRUCTIONS.to_string(),
}); });
if sandbox_enabled {
combined = Some(match combined {
Some(existing) => format!("{}\n\n{}", existing, SANDBOX_INSTRUCTIONS),
None => SANDBOX_INSTRUCTIONS.to_string(),
});
}
combined combined
} }
/// Compute a fingerprint string for the custom environment variables. /// Compute a fingerprint string for the custom environment variables.
/// Sorted alphabetically so order changes do not cause spurious recreation. /// Sorted alphabetically so order changes do not cause spurious recreation.
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"]; let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"];
let mut parts: Vec<String> = Vec::new(); let mut parts: Vec<String> = Vec::new();
for env_var in custom_env_vars { for env_var in custom_env_vars {
let key = env_var.key.trim(); let key = env_var.key.trim();
if key.is_empty() { if key.is_empty() {
continue; continue;
} }
let is_reserved = reserved_prefixes.iter().any(|p| key.to_uppercase().starts_with(p)); let upper = key.to_uppercase();
let is_reserved = reserved_prefixes.iter().any(|p| upper.starts_with(p))
|| reserved_exact.iter().any(|e| upper == *e);
if is_reserved { if is_reserved {
continue; continue;
} }
@@ -211,9 +256,25 @@ fn sha256_hex(input: &str) -> String {
format!("{:x}", hasher.finalize()) format!("{:x}", hasher.finalize())
} }
/// Resolve a per-project string value with a global fallback. Returns `None`
/// when both are blank, otherwise the per-project value if set, else the global.
fn resolve_with_global<'a>(per_project: Option<&'a str>, global: Option<&'a str>) -> Option<&'a str> {
let project_val = per_project.map(str::trim).filter(|s| !s.is_empty());
if project_val.is_some() {
return project_val;
}
global.map(str::trim).filter(|s| !s.is_empty())
}
/// Compute a fingerprint for the Bedrock configuration so we can detect changes. /// Compute a fingerprint for the Bedrock configuration so we can detect changes.
fn compute_bedrock_fingerprint(project: &Project) -> String { /// Includes the resolved model_id (per-project blank → global default) so that
/// changing the global default forces a container recreation.
fn compute_bedrock_fingerprint(project: &Project, global_aws: &GlobalAwsSettings) -> String {
if let Some(ref bedrock) = project.bedrock_config { if let Some(ref bedrock) = project.bedrock_config {
let effective_model = resolve_with_global(
bedrock.model_id.as_deref(),
global_aws.default_model_id.as_deref(),
).unwrap_or("").to_string();
let parts = vec![ let parts = vec![
format!("{:?}", bedrock.auth_method), format!("{:?}", bedrock.auth_method),
bedrock.aws_region.clone(), bedrock.aws_region.clone(),
@@ -222,8 +283,9 @@ fn compute_bedrock_fingerprint(project: &Project) -> String {
bedrock.aws_session_token.as_deref().unwrap_or("").to_string(), bedrock.aws_session_token.as_deref().unwrap_or("").to_string(),
bedrock.aws_profile.as_deref().unwrap_or("").to_string(), bedrock.aws_profile.as_deref().unwrap_or("").to_string(),
bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(), bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(),
bedrock.model_id.as_deref().unwrap_or("").to_string(), effective_model,
format!("{}", bedrock.disable_prompt_caching), format!("{}", bedrock.disable_prompt_caching),
bedrock.service_tier.as_deref().unwrap_or("").to_string(),
]; ];
sha256_hex(&parts.join("|")) sha256_hex(&parts.join("|"))
} else { } else {
@@ -232,12 +294,18 @@ fn compute_bedrock_fingerprint(project: &Project) -> String {
} }
/// Compute a fingerprint for the Ollama configuration so we can detect changes. /// Compute a fingerprint for the Ollama configuration so we can detect changes.
fn compute_ollama_fingerprint(project: &Project) -> String { /// Includes the resolved base_url and model_id (per-project blank → global default).
fn compute_ollama_fingerprint(project: &Project, global_ollama: &GlobalOllamaSettings) -> String {
if let Some(ref ollama) = project.ollama_config { if let Some(ref ollama) = project.ollama_config {
let parts = vec![ let effective_url = resolve_with_global(
ollama.base_url.clone(), Some(&ollama.base_url),
ollama.model_id.as_deref().unwrap_or("").to_string(), global_ollama.base_url.as_deref(),
]; ).unwrap_or("").to_string();
let effective_model = resolve_with_global(
ollama.model_id.as_deref(),
global_ollama.default_model_id.as_deref(),
).unwrap_or("").to_string();
let parts = vec![effective_url, effective_model];
sha256_hex(&parts.join("|")) sha256_hex(&parts.join("|"))
} else { } else {
String::new() String::new()
@@ -245,12 +313,24 @@ fn compute_ollama_fingerprint(project: &Project) -> String {
} }
/// Compute a fingerprint for the OpenAI Compatible configuration so we can detect changes. /// Compute a fingerprint for the OpenAI Compatible configuration so we can detect changes.
fn compute_openai_compatible_fingerprint(project: &Project) -> String { /// Includes the resolved base_url and model_id (per-project blank → global default).
fn compute_openai_compatible_fingerprint(
project: &Project,
global_openai_compatible: &GlobalOpenAiCompatibleSettings,
) -> String {
if let Some(ref config) = project.openai_compatible_config { if let Some(ref config) = project.openai_compatible_config {
let effective_url = resolve_with_global(
Some(&config.base_url),
global_openai_compatible.base_url.as_deref(),
).unwrap_or("").to_string();
let effective_model = resolve_with_global(
config.model_id.as_deref(),
global_openai_compatible.default_model_id.as_deref(),
).unwrap_or("").to_string();
let parts = vec![ let parts = vec![
config.base_url.clone(), effective_url,
config.api_key.as_deref().unwrap_or("").to_string(), config.api_key.as_deref().unwrap_or("").to_string(),
config.model_id.as_deref().unwrap_or("").to_string(), effective_model,
]; ];
sha256_hex(&parts.join("|")) sha256_hex(&parts.join("|"))
} else { } else {
@@ -282,6 +362,117 @@ fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
sha256_hex(&joined) sha256_hex(&joined)
} }
/// Merge global and per-project ClaudeCodeSettings.
/// Per-project fields override global fields when set (non-default).
fn merge_claude_code_settings(
global: Option<&ClaudeCodeSettings>,
project: Option<&ClaudeCodeSettings>,
) -> Option<ClaudeCodeSettings> {
match (global, project) {
(None, None) => None,
(Some(g), None) => Some(g.clone()),
(None, Some(p)) => Some(p.clone()),
(Some(g), Some(p)) => {
// Project overrides global for each field when the project value is non-default
Some(ClaudeCodeSettings {
tui_mode: p.tui_mode.clone().or_else(|| g.tui_mode.clone()),
effort: p.effort.clone().or_else(|| g.effort.clone()),
auto_scroll_disabled: if p.auto_scroll_disabled { true } else { g.auto_scroll_disabled },
focus_mode: if p.focus_mode { true } else { g.focus_mode },
show_thinking_summaries: if p.show_thinking_summaries { true } else { g.show_thinking_summaries },
enable_session_recap: if p.enable_session_recap { true } else { g.enable_session_recap },
env_scrub: if p.env_scrub { true } else { g.env_scrub },
prompt_caching_1h: if p.prompt_caching_1h { true } else { g.prompt_caching_1h },
})
}
}
}
/// Compute a fingerprint for the Claude Code settings so we can detect changes.
/// The `sandbox_enabled` flag is included so that toggling sandbox mode forces
/// a container recreation (re-injecting the merged settings.json). When
/// sandbox is off the historical fingerprint is preserved unchanged so that
/// upgrading triple-c does not spuriously flag every existing container for
/// recreation.
fn compute_claude_code_settings_fingerprint(
settings: Option<&ClaudeCodeSettings>,
sandbox_enabled: bool,
) -> String {
let base_fp = match settings {
None => String::new(),
Some(s) => {
let parts = vec![
s.tui_mode.as_deref().unwrap_or("").to_string(),
s.effort.as_deref().unwrap_or("").to_string(),
format!("{}", s.auto_scroll_disabled),
format!("{}", s.focus_mode),
format!("{}", s.show_thinking_summaries),
format!("{}", s.enable_session_recap),
format!("{}", s.env_scrub),
format!("{}", s.prompt_caching_1h),
];
sha256_hex(&parts.join("|"))
}
};
if sandbox_enabled {
sha256_hex(&format!("{}|sandbox=true", base_fp))
} else {
base_fp
}
}
/// Build the settings.json content for Claude Code.
/// Returns a JSON string of the settings to be written to ~/.claude/settings.json.
/// Always emits a `sandbox.enabled` key reflecting the current per-project
/// toggle so that flipping it off in triple-c overrides any prior on-state
/// stored in the persisted settings.json (which lives in a named volume).
fn build_claude_code_settings_json(
settings: Option<&ClaudeCodeSettings>,
sandbox_enabled: bool,
) -> Option<String> {
let mut map = serde_json::Map::new();
if let Some(s) = settings {
if let Some(ref tui) = s.tui_mode {
map.insert("tui".to_string(), serde_json::json!(tui));
}
if let Some(ref effort) = s.effort {
map.insert("effort".to_string(), serde_json::json!(effort));
}
if s.auto_scroll_disabled {
map.insert("autoScrollEnabled".to_string(), serde_json::json!(false));
}
if s.focus_mode {
map.insert("focusMode".to_string(), serde_json::json!(true));
}
if s.show_thinking_summaries {
map.insert("showThinkingSummaries".to_string(), serde_json::json!(true));
}
}
// Always emit `sandbox.enabled` so that toggling the per-project sandbox
// off in triple-c clears any prior on-state in the persisted
// settings.json (which lives in a named volume that survives recreation).
// Inside a Docker container we can't rely on privileged user namespaces,
// so `enableWeakerNestedSandbox` is required when sandbox is on.
let sandbox_obj = if sandbox_enabled {
serde_json::json!({
"enabled": true,
"enableWeakerNestedSandbox": true,
"allowUnsandboxedCommands": false,
})
} else {
serde_json::json!({ "enabled": false })
};
map.insert("sandbox".to_string(), sandbox_obj);
if map.is_empty() {
None
} else {
Some(serde_json::Value::Object(map).to_string())
}
}
/// Build the JSON value for MCP servers config to be injected into ~/.claude.json. /// Build the JSON value for MCP servers config to be injected into ~/.claude.json.
/// Produces `{"mcpServers": {"name": {"type": "stdio", ...}, ...}}`. /// Produces `{"mcpServers": {"name": {"type": "stdio", ...}, ...}}`.
/// ///
@@ -395,11 +586,17 @@ pub async fn create_container(
image_name: &str, image_name: &str,
aws_config_path: Option<&str>, aws_config_path: Option<&str>,
global_aws: &GlobalAwsSettings, global_aws: &GlobalAwsSettings,
global_ollama: &GlobalOllamaSettings,
global_openai_compatible: &GlobalOpenAiCompatibleSettings,
global_claude_instructions: Option<&str>, global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar], global_custom_env_vars: &[EnvVar],
timezone: Option<&str>, timezone: Option<&str>,
mcp_servers: &[McpServer], mcp_servers: &[McpServer],
network_name: Option<&str>, network_name: Option<&str>,
global_claude_code_settings: Option<&ClaudeCodeSettings>,
default_ssh_key_path: Option<&str>,
default_git_user_name: Option<&str>,
default_git_user_email: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
let docker = get_docker()?; let docker = get_docker()?;
let container_name = project.container_name(); let container_name = project.container_name();
@@ -445,10 +642,13 @@ pub async fn create_container(
if let Some(ref token) = project.git_token { if let Some(ref token) = project.git_token {
env_vars.push(format!("GIT_TOKEN={}", token)); env_vars.push(format!("GIT_TOKEN={}", token));
} }
if let Some(ref name) = project.git_user_name { // Per-project git user overrides global defaults
let effective_git_name = project.git_user_name.as_deref().or(default_git_user_name);
let effective_git_email = project.git_user_email.as_deref().or(default_git_user_email);
if let Some(name) = effective_git_name {
env_vars.push(format!("GIT_USER_NAME={}", name)); env_vars.push(format!("GIT_USER_NAME={}", name));
} }
if let Some(ref email) = project.git_user_email { if let Some(email) = effective_git_email {
env_vars.push(format!("GIT_USER_EMAIL={}", email)); env_vars.push(format!("GIT_USER_EMAIL={}", email));
} }
@@ -495,22 +695,40 @@ pub async fn create_container(
} }
} }
if let Some(ref model) = bedrock.model_id { if let Some(model) = resolve_with_global(
bedrock.model_id.as_deref(),
global_aws.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model)); env_vars.push(format!("ANTHROPIC_MODEL={}", model));
} }
if bedrock.disable_prompt_caching { if bedrock.disable_prompt_caching {
env_vars.push("DISABLE_PROMPT_CACHING=1".to_string()); env_vars.push("DISABLE_PROMPT_CACHING=1".to_string());
} }
if let Some(ref tier) = bedrock.service_tier {
let trimmed = tier.trim();
if !trimmed.is_empty() {
env_vars.push(format!("ANTHROPIC_BEDROCK_SERVICE_TIER={}", trimmed));
}
}
} }
} }
// Ollama configuration // Ollama configuration
if project.backend == Backend::Ollama { if project.backend == Backend::Ollama {
if let Some(ref ollama) = project.ollama_config { if let Some(ref ollama) = project.ollama_config {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", ollama.base_url)); if let Some(url) = resolve_with_global(
Some(&ollama.base_url),
global_ollama.base_url.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", url));
}
env_vars.push("ANTHROPIC_AUTH_TOKEN=ollama".to_string()); env_vars.push("ANTHROPIC_AUTH_TOKEN=ollama".to_string());
if let Some(ref model) = ollama.model_id { if let Some(model) = resolve_with_global(
ollama.model_id.as_deref(),
global_ollama.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model)); env_vars.push(format!("ANTHROPIC_MODEL={}", model));
} }
} }
@@ -519,11 +737,19 @@ pub async fn create_container(
// OpenAI Compatible configuration // OpenAI Compatible configuration
if project.backend == Backend::OpenAiCompatible { if project.backend == Backend::OpenAiCompatible {
if let Some(ref config) = project.openai_compatible_config { if let Some(ref config) = project.openai_compatible_config {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", config.base_url)); if let Some(url) = resolve_with_global(
Some(&config.base_url),
global_openai_compatible.base_url.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", url));
}
if let Some(ref key) = config.api_key { if let Some(ref key) = config.api_key {
env_vars.push(format!("ANTHROPIC_AUTH_TOKEN={}", key)); env_vars.push(format!("ANTHROPIC_AUTH_TOKEN={}", key));
} }
if let Some(ref model) = config.model_id { if let Some(model) = resolve_with_global(
config.model_id.as_deref(),
global_openai_compatible.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model)); env_vars.push(format!("ANTHROPIC_MODEL={}", model));
} }
} }
@@ -531,13 +757,16 @@ pub async fn create_container(
// Custom environment variables (global + per-project, project overrides global for same key) // Custom environment variables (global + per-project, project overrides global for same key)
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars); let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"]; let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"];
for env_var in &merged_env { for env_var in &merged_env {
let key = env_var.key.trim(); let key = env_var.key.trim();
if key.is_empty() { if key.is_empty() {
continue; continue;
} }
let is_reserved = reserved_prefixes.iter().any(|p| key.to_uppercase().starts_with(p)); let upper = key.to_uppercase();
let is_reserved = reserved_prefixes.iter().any(|p| upper.starts_with(p))
|| reserved_exact.iter().any(|e| upper == *e);
if is_reserved { if is_reserved {
log::warn!("Skipping reserved env var: {}", key); log::warn!("Skipping reserved env var: {}", key);
continue; continue;
@@ -565,6 +794,7 @@ pub async fn create_container(
project.claude_instructions.as_deref(), project.claude_instructions.as_deref(),
&project.port_mappings, &project.port_mappings,
project.mission_control_enabled, project.mission_control_enabled,
project.sandbox_mode_enabled,
); );
if let Some(ref instructions) = combined_instructions { if let Some(ref instructions) = combined_instructions {
@@ -577,6 +807,37 @@ pub async fn create_container(
env_vars.push(format!("MCP_SERVERS_JSON={}", mcp_json)); env_vars.push(format!("MCP_SERVERS_JSON={}", mcp_json));
} }
// Claude Code settings (global + per-project merged)
let merged_cc_settings = merge_claude_code_settings(
global_claude_code_settings,
project.claude_code_settings.as_ref(),
);
if let Some(ref cc) = merged_cc_settings {
// Env-var-based settings (these are read directly by Claude Code)
if cc.tui_mode.as_deref() == Some("fullscreen") {
env_vars.push("CLAUDE_CODE_NO_FLICKER=1".to_string());
}
if cc.enable_session_recap {
env_vars.push("CLAUDE_CODE_ENABLE_AWAY_SUMMARY=1".to_string());
}
if cc.env_scrub {
env_vars.push("CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1".to_string());
}
if cc.prompt_caching_1h {
env_vars.push("ENABLE_PROMPT_CACHING_1H=1".to_string());
}
}
// settings.json-based settings (written by the entrypoint).
// Always invoked so per-project sandbox state is injected even when no
// ClaudeCodeSettings struct is present.
if let Some(settings_json) = build_claude_code_settings_json(
merged_cc_settings.as_ref(),
project.sandbox_mode_enabled,
) {
env_vars.push(format!("CLAUDE_CODE_SETTINGS_JSON={}", settings_json));
}
let mut mounts: Vec<Mount> = Vec::new(); let mut mounts: Vec<Mount> = Vec::new();
// Project directories -> /workspace/{mount_name} // Project directories -> /workspace/{mount_name}
@@ -612,10 +873,12 @@ pub async fn create_container(
}); });
// SSH keys mount (read-only staging; entrypoint copies to ~/.ssh with correct perms) // SSH keys mount (read-only staging; entrypoint copies to ~/.ssh with correct perms)
if let Some(ref ssh_path) = project.ssh_key_path { // Per-project ssh_key_path overrides global default_ssh_key_path
let effective_ssh_path = project.ssh_key_path.as_deref().or(default_ssh_key_path);
if let Some(ssh_path) = effective_ssh_path {
mounts.push(Mount { mounts.push(Mount {
target: Some("/tmp/.host-ssh".to_string()), target: Some("/tmp/.host-ssh".to_string()),
source: Some(ssh_path.clone()), source: Some(ssh_path.to_string()),
typ: Some(MountTypeEnum::BIND), typ: Some(MountTypeEnum::BIND),
read_only: Some(true), read_only: Some(true),
..Default::default() ..Default::default()
@@ -696,19 +959,21 @@ pub async fn create_container(
labels.insert("triple-c.project-name".to_string(), project.name.clone()); labels.insert("triple-c.project-name".to_string(), project.name.clone());
labels.insert("triple-c.backend".to_string(), format!("{:?}", project.backend)); labels.insert("triple-c.backend".to_string(), format!("{:?}", project.backend));
labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths)); labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths));
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project)); labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project, global_aws));
labels.insert("triple-c.ollama-fingerprint".to_string(), compute_ollama_fingerprint(project)); labels.insert("triple-c.ollama-fingerprint".to_string(), compute_ollama_fingerprint(project, global_ollama));
labels.insert("triple-c.openai-compatible-fingerprint".to_string(), compute_openai_compatible_fingerprint(project)); labels.insert("triple-c.openai-compatible-fingerprint".to_string(), compute_openai_compatible_fingerprint(project, global_openai_compatible));
labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings)); labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
labels.insert("triple-c.image".to_string(), image_name.to_string()); labels.insert("triple-c.image".to_string(), image_name.to_string());
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string()); labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers)); labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers));
labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string()); labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string());
labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone()); labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone());
labels.insert("triple-c.claude-code-settings-fingerprint".to_string(),
compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref(), project.sandbox_mode_enabled));
labels.insert("triple-c.instructions-fingerprint".to_string(), labels.insert("triple-c.instructions-fingerprint".to_string(),
combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default()); combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default());
labels.insert("triple-c.git-user-name".to_string(), project.git_user_name.clone().unwrap_or_default()); labels.insert("triple-c.git-user-name".to_string(), effective_git_name.unwrap_or_default().to_string());
labels.insert("triple-c.git-user-email".to_string(), project.git_user_email.clone().unwrap_or_default()); labels.insert("triple-c.git-user-email".to_string(), effective_git_email.unwrap_or_default().to_string());
labels.insert("triple-c.git-token-hash".to_string(), labels.insert("triple-c.git-token-hash".to_string(),
project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default()); project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default());
@@ -873,10 +1138,17 @@ pub async fn remove_project_volumes(project: &Project) -> Result<(), String> {
pub async fn container_needs_recreation( pub async fn container_needs_recreation(
container_id: &str, container_id: &str,
project: &Project, project: &Project,
global_aws: &GlobalAwsSettings,
global_ollama: &GlobalOllamaSettings,
global_openai_compatible: &GlobalOpenAiCompatibleSettings,
global_claude_instructions: Option<&str>, global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar], global_custom_env_vars: &[EnvVar],
timezone: Option<&str>, timezone: Option<&str>,
mcp_servers: &[McpServer], mcp_servers: &[McpServer],
global_claude_code_settings: Option<&ClaudeCodeSettings>,
default_ssh_key_path: Option<&str>,
default_git_user_name: Option<&str>,
default_git_user_email: Option<&str>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let docker = get_docker()?; let docker = get_docker()?;
let info = docker let info = docker
@@ -940,7 +1212,7 @@ pub async fn container_needs_recreation(
} }
// ── Bedrock config fingerprint ─────────────────────────────────────── // ── Bedrock config fingerprint ───────────────────────────────────────
let expected_bedrock_fp = compute_bedrock_fingerprint(project); let expected_bedrock_fp = compute_bedrock_fingerprint(project, global_aws);
let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default(); let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default();
if container_bedrock_fp != expected_bedrock_fp { if container_bedrock_fp != expected_bedrock_fp {
log::info!("Bedrock config mismatch"); log::info!("Bedrock config mismatch");
@@ -948,7 +1220,7 @@ pub async fn container_needs_recreation(
} }
// ── Ollama config fingerprint ──────────────────────────────────────── // ── Ollama config fingerprint ────────────────────────────────────────
let expected_ollama_fp = compute_ollama_fingerprint(project); let expected_ollama_fp = compute_ollama_fingerprint(project, global_ollama);
let container_ollama_fp = get_label("triple-c.ollama-fingerprint").unwrap_or_default(); let container_ollama_fp = get_label("triple-c.ollama-fingerprint").unwrap_or_default();
if container_ollama_fp != expected_ollama_fp { if container_ollama_fp != expected_ollama_fp {
log::info!("Ollama config mismatch"); log::info!("Ollama config mismatch");
@@ -956,7 +1228,7 @@ pub async fn container_needs_recreation(
} }
// ── OpenAI Compatible config fingerprint ──────────────────────────── // ── OpenAI Compatible config fingerprint ────────────────────────────
let expected_oai_fp = compute_openai_compatible_fingerprint(project); let expected_oai_fp = compute_openai_compatible_fingerprint(project, global_openai_compatible);
let container_oai_fp = get_label("triple-c.openai-compatible-fingerprint").unwrap_or_default(); let container_oai_fp = get_label("triple-c.openai-compatible-fingerprint").unwrap_or_default();
if container_oai_fp != expected_oai_fp { if container_oai_fp != expected_oai_fp {
log::info!("OpenAI Compatible config mismatch"); log::info!("OpenAI Compatible config mismatch");
@@ -997,28 +1269,34 @@ pub async fn container_needs_recreation(
.find(|mount| mount.target.as_deref() == Some("/tmp/.host-ssh")) .find(|mount| mount.target.as_deref() == Some("/tmp/.host-ssh"))
}) })
.and_then(|mount| mount.source.as_deref()); .and_then(|mount| mount.source.as_deref());
let project_ssh = project.ssh_key_path.as_deref(); let effective_ssh = project.ssh_key_path.as_deref().or(default_ssh_key_path);
if ssh_mount_source != project_ssh { if ssh_mount_source != effective_ssh {
log::info!( log::info!(
"SSH key path mismatch (container={:?}, project={:?})", "SSH key path mismatch (container={:?}, expected={:?})",
ssh_mount_source, ssh_mount_source,
project_ssh effective_ssh
); );
return Ok(true); return Ok(true);
} }
// ── Git settings (label-based to avoid stale snapshot env vars) ───── // ── Git settings (label-based to avoid stale snapshot env vars) ─────
let expected_git_name = project.git_user_name.clone().unwrap_or_default(); let expected_git_name = project.git_user_name.as_deref()
.or(default_git_user_name)
.unwrap_or_default()
.to_string();
let container_git_name = get_label("triple-c.git-user-name").unwrap_or_default(); let container_git_name = get_label("triple-c.git-user-name").unwrap_or_default();
if container_git_name != expected_git_name { if container_git_name != expected_git_name {
log::info!("GIT_USER_NAME mismatch (container={:?}, project={:?})", container_git_name, expected_git_name); log::info!("GIT_USER_NAME mismatch (container={:?}, expected={:?})", container_git_name, expected_git_name);
return Ok(true); return Ok(true);
} }
let expected_git_email = project.git_user_email.clone().unwrap_or_default(); let expected_git_email = project.git_user_email.as_deref()
.or(default_git_user_email)
.unwrap_or_default()
.to_string();
let container_git_email = get_label("triple-c.git-user-email").unwrap_or_default(); let container_git_email = get_label("triple-c.git-user-email").unwrap_or_default();
if container_git_email != expected_git_email { if container_git_email != expected_git_email {
log::info!("GIT_USER_EMAIL mismatch (container={:?}, project={:?})", container_git_email, expected_git_email); log::info!("GIT_USER_EMAIL mismatch (container={:?}, expected={:?})", container_git_email, expected_git_email);
return Ok(true); return Ok(true);
} }
@@ -1052,6 +1330,7 @@ pub async fn container_needs_recreation(
project.claude_instructions.as_deref(), project.claude_instructions.as_deref(),
&project.port_mappings, &project.port_mappings,
project.mission_control_enabled, project.mission_control_enabled,
project.sandbox_mode_enabled,
); );
let expected_instructions_fp = expected_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default(); let expected_instructions_fp = expected_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default();
let container_instructions_fp = get_label("triple-c.instructions-fingerprint").unwrap_or_default(); let container_instructions_fp = get_label("triple-c.instructions-fingerprint").unwrap_or_default();
@@ -1060,6 +1339,18 @@ pub async fn container_needs_recreation(
return Ok(true); return Ok(true);
} }
// ── Claude Code settings fingerprint ───────────────────────────────
let merged_cc = merge_claude_code_settings(
global_claude_code_settings,
project.claude_code_settings.as_ref(),
);
let expected_cc_fp = compute_claude_code_settings_fingerprint(merged_cc.as_ref(), project.sandbox_mode_enabled);
let container_cc_fp = get_label("triple-c.claude-code-settings-fingerprint").unwrap_or_default();
if container_cc_fp != expected_cc_fp {
log::info!("Claude Code settings mismatch (container={:?}, expected={:?})", container_cc_fp, expected_cc_fp);
return Ok(true);
}
// ── MCP servers fingerprint ───────────────────────────────────────── // ── MCP servers fingerprint ─────────────────────────────────────────
let expected_mcp_fp = compute_mcp_fingerprint(mcp_servers); let expected_mcp_fp = compute_mcp_fingerprint(mcp_servers);
let container_mcp_fp = get_label("triple-c.mcp-fingerprint").unwrap_or_default(); let container_mcp_fp = get_label("triple-c.mcp-fingerprint").unwrap_or_default();

View File

@@ -0,0 +1,53 @@
// Helpers for detecting whether Docker (or a Docker-compatible runtime) is
// installed on the host and, when missing, offering to install it for the user.
//
// We use the Docker convenience script on Linux and Rancher Desktop on macOS /
// Windows. On every platform we also surface an official documentation URL so
// users without a recognised package manager can install manually.
use serde::{Deserialize, Serialize};
pub mod platform;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstallOptions {
/// "linux" | "macos" | "windows" | "unknown"
pub os: String,
/// User-facing name of what we'd install ("Docker Engine" / "Rancher Desktop").
pub product_name: String,
/// Whether we can kick off a one-click install with what's on this machine.
pub can_auto_install: bool,
/// Short identifier of the method we'd use ("pkexec", "brew", "winget", or None).
pub auto_install_method: Option<String>,
/// If auto-install isn't possible, a human-readable reason to show the user.
pub auto_install_blocker: Option<String>,
/// Official documentation URL for manual install.
pub docs_url: String,
/// Ordered manual install steps (plain text lines).
pub manual_steps: Vec<String>,
/// Notes to display after a successful auto-install (e.g. log out/back in).
pub post_install_notes: Vec<String>,
}
pub fn detect_install_options() -> InstallOptions {
if cfg!(target_os = "linux") {
platform::linux_options()
} else if cfg!(target_os = "macos") {
platform::macos_options()
} else if cfg!(target_os = "windows") {
platform::windows_options()
} else {
InstallOptions {
os: "unknown".into(),
product_name: "Docker".into(),
can_auto_install: false,
auto_install_method: None,
auto_install_blocker: Some("Unsupported operating system".into()),
docs_url: "https://docs.docker.com/get-docker/".into(),
manual_steps: vec![
"Visit the Docker documentation and follow the install guide for your OS.".into(),
],
post_install_notes: vec![],
}
}
}

View File

@@ -0,0 +1,288 @@
use std::path::PathBuf;
use std::process::Stdio;
use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use super::InstallOptions;
const PROGRESS_EVENT: &str = "docker-install-progress";
fn which(cmd: &str) -> bool {
find_on_path(cmd).is_some()
}
/// Search PATH for an executable, plus a handful of well-known locations that
/// GUI-launched apps on macOS/Linux typically miss (Homebrew prefixes, etc.).
fn find_on_path(cmd: &str) -> Option<PathBuf> {
#[cfg(unix)]
let extra: &[&str] = &[
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
];
#[cfg(windows)]
let extra: &[&str] = &[];
if let Ok(path) = std::env::var("PATH") {
let sep = if cfg!(windows) { ';' } else { ':' };
for dir in path.split(sep).chain(extra.iter().copied()) {
let candidate = PathBuf::from(dir).join(cmd);
if candidate.is_file() {
return Some(candidate);
}
#[cfg(windows)]
for ext in ["exe", "cmd", "bat"] {
let mut with_ext = candidate.clone();
with_ext.set_extension(ext);
if with_ext.is_file() {
return Some(with_ext);
}
}
}
}
for dir in extra {
let candidate = PathBuf::from(dir).join(cmd);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
async fn stream(app: &AppHandle, mut child: tokio::process::Child) -> Result<(), String> {
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let app_out = app.clone();
let out_task = tokio::spawn(async move {
if let Some(out) = stdout {
let mut lines = BufReader::new(out).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = app_out.emit(PROGRESS_EVENT, line);
}
}
});
let app_err = app.clone();
let err_task = tokio::spawn(async move {
if let Some(err) = stderr {
let mut lines = BufReader::new(err).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = app_err.emit(PROGRESS_EVENT, line);
}
}
});
let status = child
.wait()
.await
.map_err(|e| format!("install process failed: {}", e))?;
let _ = out_task.await;
let _ = err_task.await;
if !status.success() {
return Err(format!(
"installer exited with status {}",
status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into())
));
}
Ok(())
}
// ─── Linux ───────────────────────────────────────────────────────────────────
pub fn linux_options() -> InstallOptions {
let has_pkexec = which("pkexec");
let has_curl = which("curl");
let (can_auto, blocker) = match (has_pkexec, has_curl) {
(true, true) => (true, None),
(false, _) => (
false,
Some("pkexec not found — install policykit-1 or follow manual steps.".into()),
),
(_, false) => (
false,
Some("curl not found — install curl or follow manual steps.".into()),
),
};
InstallOptions {
os: "linux".into(),
product_name: "Docker Engine".into(),
can_auto_install: can_auto,
auto_install_method: if can_auto { Some("pkexec".into()) } else { None },
auto_install_blocker: blocker,
docs_url: "https://docs.docker.com/engine/install/".into(),
manual_steps: vec![
"Open a terminal.".into(),
"Run: curl -fsSL https://get.docker.com | sh".into(),
"Add yourself to the docker group: sudo usermod -aG docker $USER".into(),
"Log out and log back in for group changes to take effect.".into(),
],
post_install_notes: vec![
"Log out and log back in (or reboot) so your user picks up the docker group.".into(),
"If Docker isn't detected after re-login, start the service: sudo systemctl start docker".into(),
],
}
}
async fn run_linux_install(app: &AppHandle) -> Result<(), String> {
// Grab the current username so pkexec (which runs as root) can add the
// original invoking user to the docker group.
let invoking_user = std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.map_err(|_| "could not determine invoking username".to_string())?;
// Write a self-contained installer script to a temp file. Running the
// Docker convenience script then appending the user to the docker group
// and enabling the service.
let script = format!(
r#"#!/bin/sh
set -e
echo "[triple-c] Downloading Docker install script..."
curl -fsSL https://get.docker.com -o /tmp/triple-c-get-docker.sh
echo "[triple-c] Running Docker install script (may take a few minutes)..."
sh /tmp/triple-c-get-docker.sh
rm -f /tmp/triple-c-get-docker.sh
echo "[triple-c] Adding {user} to docker group..."
usermod -aG docker "{user}" || true
echo "[triple-c] Enabling docker service..."
systemctl enable --now docker 2>/dev/null || service docker start 2>/dev/null || true
echo "[triple-c] Install complete. Log out and back in to use Docker without sudo."
"#,
user = invoking_user
);
let script_path: PathBuf = std::env::temp_dir().join("triple-c-install-docker.sh");
tokio::fs::write(&script_path, script)
.await
.map_err(|e| format!("failed to write install script: {}", e))?;
let _ = app.emit(
PROGRESS_EVENT,
format!("Requesting administrator privileges via pkexec..."),
);
let child = Command::new("pkexec")
.arg("sh")
.arg(&script_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("failed to launch pkexec: {}", e))?;
let result = stream(app, child).await;
let _ = tokio::fs::remove_file(&script_path).await;
result
}
// ─── macOS ───────────────────────────────────────────────────────────────────
pub fn macos_options() -> InstallOptions {
let has_brew = which("brew");
InstallOptions {
os: "macos".into(),
product_name: "Rancher Desktop".into(),
can_auto_install: has_brew,
auto_install_method: if has_brew { Some("brew".into()) } else { None },
auto_install_blocker: if has_brew {
None
} else {
Some("Homebrew not found — use the manual download.".into())
},
docs_url: "https://docs.rancherdesktop.io/getting-started/installation/".into(),
manual_steps: vec![
"Download the Rancher Desktop .dmg from the official site.".into(),
"Open the .dmg and drag Rancher Desktop into Applications.".into(),
"Launch Rancher Desktop and complete the first-run setup (choose dockerd/moby).".into(),
"Once the Docker socket is available, come back and click Refresh.".into(),
],
post_install_notes: vec![
"Launch Rancher Desktop from Applications if it didn't open automatically.".into(),
"In Preferences, make sure the container engine is set to dockerd (moby).".into(),
],
}
}
async fn run_macos_install(app: &AppHandle) -> Result<(), String> {
let brew = find_on_path("brew")
.ok_or_else(|| "Homebrew not found — follow the manual steps instead.".to_string())?;
let _ = app.emit(
PROGRESS_EVENT,
format!("Running: {} install --cask rancher", brew.display()),
);
let child = Command::new(&brew)
.args(["install", "--cask", "rancher"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("failed to launch brew: {}", e))?;
stream(app, child).await
}
// ─── Windows ─────────────────────────────────────────────────────────────────
pub fn windows_options() -> InstallOptions {
let has_winget = which("winget");
InstallOptions {
os: "windows".into(),
product_name: "Rancher Desktop".into(),
can_auto_install: has_winget,
auto_install_method: if has_winget { Some("winget".into()) } else { None },
auto_install_blocker: if has_winget {
None
} else {
Some("winget not found — use the manual download.".into())
},
docs_url: "https://docs.rancherdesktop.io/getting-started/installation/".into(),
manual_steps: vec![
"Download the Rancher Desktop .msi from the official site.".into(),
"Run the installer and accept the WSL2 prompts if asked.".into(),
"Launch Rancher Desktop and complete the first-run setup (choose dockerd/moby).".into(),
"Once the Docker engine is running, come back and click Refresh.".into(),
],
post_install_notes: vec![
"Launch Rancher Desktop from the Start menu if it didn't open automatically.".into(),
"In Preferences > Container Engine, make sure dockerd (moby) is selected.".into(),
],
}
}
async fn run_windows_install(app: &AppHandle) -> Result<(), String> {
let _ = app.emit(
PROGRESS_EVENT,
"Running: winget install --id SUSE.RancherDesktop -e --accept-package-agreements --accept-source-agreements".to_string(),
);
let child = Command::new("winget")
.args([
"install",
"--id",
"SUSE.RancherDesktop",
"-e",
"--accept-package-agreements",
"--accept-source-agreements",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("failed to launch winget: {}", e))?;
stream(app, child).await
}
// ─── Dispatcher ──────────────────────────────────────────────────────────────
pub async fn run_install(app: &AppHandle) -> Result<(), String> {
if cfg!(target_os = "linux") {
run_linux_install(app).await
} else if cfg!(target_os = "macos") {
run_macos_install(app).await
} else if cfg!(target_os = "windows") {
run_windows_install(app).await
} else {
Err("auto-install is not supported on this OS".into())
}
}

View File

@@ -1,5 +1,6 @@
mod commands; mod commands;
mod docker; mod docker;
mod install_helper;
mod logging; mod logging;
mod models; mod models;
mod storage; mod storage;
@@ -111,6 +112,25 @@ pub fn run() {
} }
} }
// Auto-start STT container if enabled in settings
if settings.stt.enabled {
let stt_settings = settings.stt.clone();
tauri::async_runtime::spawn(async move {
match docker::stt::ensure_stt_running(&stt_settings).await {
Ok(status) => {
if status.running {
log::info!("STT container auto-started on port {}", stt_settings.port);
} else {
log::warn!("STT auto-start: container not running after ensure_stt_running");
}
}
Err(e) => {
log::error!("Failed to auto-start STT container: {}", e);
}
}
});
}
Ok(()) Ok(())
}) })
.on_window_event(|window, event| { .on_window_event(|window, event| {
@@ -178,6 +198,9 @@ pub fn run() {
commands::update_commands::check_image_update, commands::update_commands::check_image_update,
// Help // Help
commands::help_commands::get_help_content, commands::help_commands::get_help_content,
// Install helper
commands::install_helper_commands::detect_install_options,
commands::install_helper_commands::run_docker_install,
// Web Terminal // Web Terminal
commands::web_terminal_commands::start_web_terminal, commands::web_terminal_commands::start_web_terminal,
commands::web_terminal_commands::stop_web_terminal, commands::web_terminal_commands::stop_web_terminal,

View File

@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::project::EnvVar; use super::project::{ClaudeCodeSettings, EnvVar};
fn default_true() -> bool { fn default_true() -> bool {
true true
@@ -32,6 +32,8 @@ pub struct GlobalAwsSettings {
pub aws_profile: Option<String>, pub aws_profile: Option<String>,
#[serde(default)] #[serde(default)]
pub aws_region: Option<String>, pub aws_region: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
} }
impl Default for GlobalAwsSettings { impl Default for GlobalAwsSettings {
@@ -40,10 +42,27 @@ impl Default for GlobalAwsSettings {
aws_config_path: None, aws_config_path: None,
aws_profile: None, aws_profile: None,
aws_region: None, aws_region: None,
default_model_id: None,
} }
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlobalOllamaSettings {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlobalOpenAiCompatibleSettings {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppSettings { pub struct AppSettings {
#[serde(default)] #[serde(default)]
@@ -60,6 +79,10 @@ pub struct AppSettings {
pub custom_image_name: Option<String>, pub custom_image_name: Option<String>,
#[serde(default)] #[serde(default)]
pub global_aws: GlobalAwsSettings, pub global_aws: GlobalAwsSettings,
#[serde(default)]
pub global_ollama: GlobalOllamaSettings,
#[serde(default)]
pub global_openai_compatible: GlobalOpenAiCompatibleSettings,
#[serde(default = "default_global_instructions")] #[serde(default = "default_global_instructions")]
pub global_claude_instructions: Option<String>, pub global_claude_instructions: Option<String>,
#[serde(default)] #[serde(default)]
@@ -78,6 +101,8 @@ pub struct AppSettings {
pub web_terminal: WebTerminalSettings, pub web_terminal: WebTerminalSettings,
#[serde(default)] #[serde(default)]
pub stt: SttSettings, pub stt: SttSettings,
#[serde(default)]
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
} }
fn default_stt_model() -> String { fn default_stt_model() -> String {
@@ -154,6 +179,8 @@ impl Default for AppSettings {
image_source: ImageSource::default(), image_source: ImageSource::default(),
custom_image_name: None, custom_image_name: None,
global_aws: GlobalAwsSettings::default(), global_aws: GlobalAwsSettings::default(),
global_ollama: GlobalOllamaSettings::default(),
global_openai_compatible: GlobalOpenAiCompatibleSettings::default(),
global_claude_instructions: default_global_instructions(), global_claude_instructions: default_global_instructions(),
global_custom_env_vars: Vec::new(), global_custom_env_vars: Vec::new(),
auto_check_updates: true, auto_check_updates: true,
@@ -163,6 +190,7 @@ impl Default for AppSettings {
dismissed_image_digest: None, dismissed_image_digest: None,
web_terminal: WebTerminalSettings::default(), web_terminal: WebTerminalSettings::default(),
stt: SttSettings::default(), stt: SttSettings::default(),
global_claude_code_settings: None,
} }
} }
} }

View File

@@ -1,3 +1,5 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -28,6 +30,36 @@ fn default_full_permissions() -> bool {
true true
} }
/// Settings for Claude Code CLI behavior inside the container.
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ClaudeCodeSettings {
/// TUI rendering mode: None = default, Some("fullscreen") = flicker-free alt-screen
#[serde(default)]
pub tui_mode: Option<String>,
/// Effort level: None = default, Some("low"|"medium"|"high")
#[serde(default)]
pub effort: Option<String>,
/// Disable auto-scroll in fullscreen TUI mode
#[serde(default)]
pub auto_scroll_disabled: bool,
/// Enable focus mode (collapsed tool output)
#[serde(default)]
pub focus_mode: bool,
/// Show thinking summaries in responses
#[serde(default)]
pub show_thinking_summaries: bool,
/// Enable session recap when returning to a session
#[serde(default)]
pub enable_session_recap: bool,
/// Strip credentials from subprocess environments
#[serde(default)]
pub env_scrub: bool,
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
#[serde(default)]
pub prompt_caching_1h: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project { pub struct Project {
pub id: String, pub id: String,
@@ -43,6 +75,8 @@ pub struct Project {
pub openai_compatible_config: Option<OpenAiCompatibleConfig>, pub openai_compatible_config: Option<OpenAiCompatibleConfig>,
pub allow_docker_access: bool, pub allow_docker_access: bool,
#[serde(default)] #[serde(default)]
pub sandbox_mode_enabled: bool,
#[serde(default)]
pub mission_control_enabled: bool, pub mission_control_enabled: bool,
#[serde(default = "default_full_permissions")] #[serde(default = "default_full_permissions")]
pub full_permissions: bool, pub full_permissions: bool,
@@ -59,6 +93,11 @@ pub struct Project {
pub claude_instructions: Option<String>, pub claude_instructions: Option<String>,
#[serde(default)] #[serde(default)]
pub enabled_mcp_servers: Vec<String>, pub enabled_mcp_servers: Vec<String>,
#[serde(default)]
pub claude_code_settings: Option<ClaudeCodeSettings>,
/// User-defined display names for terminal tabs, keyed by session id.
#[serde(default)]
pub renamed_session_names: HashMap<String, String>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
@@ -127,6 +166,10 @@ pub struct BedrockConfig {
pub aws_bearer_token: Option<String>, pub aws_bearer_token: Option<String>,
pub model_id: Option<String>, pub model_id: Option<String>,
pub disable_prompt_caching: bool, pub disable_prompt_caching: bool,
/// Optional value for the `ANTHROPIC_BEDROCK_SERVICE_TIER` env var
/// (e.g. "priority"). Empty/None means leave unset.
#[serde(default)]
pub service_tier: Option<String>,
} }
/// Ollama configuration for a project. /// Ollama configuration for a project.
@@ -167,6 +210,7 @@ impl Project {
ollama_config: None, ollama_config: None,
openai_compatible_config: None, openai_compatible_config: None,
allow_docker_access: false, allow_docker_access: false,
sandbox_mode_enabled: false,
mission_control_enabled: false, mission_control_enabled: false,
full_permissions: false, full_permissions: false,
ssh_key_path: None, ssh_key_path: None,
@@ -177,6 +221,8 @@ impl Project {
port_mappings: Vec::new(), port_mappings: Vec::new(),
claude_instructions: None, claude_instructions: None,
enabled_mcp_servers: Vec::new(), enabled_mcp_servers: Vec::new(),
claude_code_settings: None,
renamed_session_names: HashMap::new(),
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
} }

View File

@@ -159,7 +159,7 @@
position: absolute; position: absolute;
inset: 0; inset: 0;
display: none; display: none;
padding: 4px; padding: 4px 4px 16px 4px;
} }
.terminal-container.active { display: block; } .terminal-container.active { display: block; }

View File

@@ -7,6 +7,7 @@ use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::commands::aws_commands;
use crate::models::{Backend, BedrockAuthMethod, Project, ProjectStatus}; use crate::models::{Backend, BedrockAuthMethod, Project, ProjectStatus};
use super::server::WebTerminalState; use super::server::WebTerminalState;
@@ -212,12 +213,10 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin
return cmd; return cmd;
} }
let profile = project let profile = aws_commands::resolve_profile_for_project(
.bedrock_config project,
.as_ref() settings_store.get().global_aws.aws_profile.as_deref(),
.and_then(|b| b.aws_profile.clone()) );
.or_else(|| settings_store.get().global_aws.aws_profile.clone())
.unwrap_or_else(|| "default".to_string());
let claude_cmd = if project.full_permissions { let claude_cmd = if project.full_permissions {
"exec claude --dangerously-skip-permissions" "exec claude --dangerously-skip-permissions"

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json", "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
"productName": "Triple-C", "productName": "Triple-C",
"version": "0.2.0", "version": "0.3.0",
"identifier": "com.triple-c.desktop", "identifier": "com.triple-c.desktop",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@@ -1,9 +1,10 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import Sidebar from "./components/layout/Sidebar"; import Sidebar from "./components/layout/Sidebar";
import TopBar from "./components/layout/TopBar"; import TopBar from "./components/layout/TopBar";
import StatusBar from "./components/layout/StatusBar"; import StatusBar from "./components/layout/StatusBar";
import TerminalView from "./components/terminal/TerminalView"; import TerminalView from "./components/terminal/TerminalView";
import DockerInstallDialog from "./components/DockerInstallDialog";
import { useDocker } from "./hooks/useDocker"; import { useDocker } from "./hooks/useDocker";
import { useSettings } from "./hooks/useSettings"; import { useSettings } from "./hooks/useSettings";
import { useProjects } from "./hooks/useProjects"; import { useProjects } from "./hooks/useProjects";
@@ -21,6 +22,7 @@ export default function App() {
const { sessions, activeSessionId, setProjects } = useAppState( const { sessions, activeSessionId, setProjects } = useAppState(
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects })) useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects }))
); );
const [showInstallDialog, setShowInstallDialog] = useState(false);
// Initialize on mount // Initialize on mount
useEffect(() => { useEffect(() => {
@@ -38,6 +40,7 @@ export default function App() {
refresh(); refresh();
}); });
} else { } else {
setShowInstallDialog(true);
stopPolling = startDockerPolling(); stopPolling = startDockerPolling();
} }
}); });
@@ -80,6 +83,9 @@ export default function App() {
</main> </main>
</div> </div>
<StatusBar /> <StatusBar />
{showInstallDialog && (
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,211 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useInstallHelper } from "../hooks/useInstallHelper";
import { useDocker } from "../hooks/useDocker";
interface Props {
onClose: () => void;
}
type Phase = "idle" | "installing" | "done" | "error";
export default function DockerInstallDialog({ onClose }: Props) {
const { options, loadOptions, runInstall } = useInstallHelper();
const { checkDocker } = useDocker();
const [showManual, setShowManual] = useState(false);
const [phase, setPhase] = useState<Phase>("idle");
const [log, setLog] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
loadOptions();
}, [loadOptions]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && phase !== "installing") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose, phase]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current && phase !== "installing") onClose();
},
[onClose, phase],
);
const handleInstall = async () => {
setPhase("installing");
setLog([]);
setError(null);
try {
await runInstall((line) => setLog((prev) => [...prev, line]));
setPhase("done");
// Re-check Docker so the rest of the app can proceed without a reload.
await checkDocker();
} catch (e) {
setError(String(e));
setPhase("error");
}
};
const handleOpenDocs = async () => {
if (!options) return;
try {
await openUrl(options.docs_url);
} catch (e) {
console.error("Failed to open docs URL:", e);
}
};
const handleRecheck = async () => {
const available = await checkDocker();
if (available) onClose();
};
if (!options) {
return null;
}
const installVerb = phase === "installing" ? "Installing…" : `Install ${options.product_name}`;
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[32rem] max-h-[85vh] overflow-y-auto shadow-xl">
<h2 className="text-lg font-semibold mb-1">Docker not detected</h2>
<p className="text-sm text-[var(--text-secondary)] mb-4">
Triple-C needs a Docker-compatible runtime to manage sandboxed project containers.
We can install <span className="text-[var(--text-primary)]">{options.product_name}</span>{" "}
for you, or you can follow the official instructions.
</p>
{phase === "idle" && (
<div className="flex flex-col gap-2">
{options.can_auto_install ? (
<button
onClick={handleInstall}
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
>
{installVerb} ({options.auto_install_method})
</button>
) : (
<div className="text-xs text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2">
One-click install unavailable:{" "}
<span className="text-[var(--text-primary)]">
{options.auto_install_blocker ?? "required tooling missing."}
</span>
</div>
)}
<button
onClick={() => setShowManual((s) => !s)}
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
>
{showManual ? "Hide manual instructions" : "Show manual instructions"}
</button>
<button
onClick={handleOpenDocs}
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
>
Open official documentation
</button>
</div>
)}
{phase === "installing" && (
<div className="text-xs text-[var(--text-secondary)]">
Installing a system password prompt may appear. Do not close this window.
</div>
)}
{phase === "done" && (
<div className="flex flex-col gap-2">
<div className="text-sm text-[var(--success)]">Install finished.</div>
{options.post_install_notes.length > 0 && (
<ul className="text-xs text-[var(--text-secondary)] list-disc list-inside space-y-1">
{options.post_install_notes.map((note, i) => (
<li key={i}>{note}</li>
))}
</ul>
)}
<div className="flex gap-2 mt-2">
<button
onClick={handleRecheck}
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
>
Re-check Docker
</button>
<button
onClick={onClose}
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
>
Close
</button>
</div>
</div>
)}
{phase === "error" && (
<div className="flex flex-col gap-2">
<div className="text-sm text-[var(--error)]">Install failed.</div>
{error && <div className="text-xs font-mono text-[var(--error)]">{error}</div>}
<div className="flex gap-2 mt-2">
<button
onClick={() => setPhase("idle")}
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
>
Back
</button>
<button
onClick={handleOpenDocs}
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
>
Open official docs
</button>
</div>
</div>
)}
{(showManual || phase === "error") && (
<div className="mt-4">
<div className="text-xs font-medium mb-1.5 text-[var(--text-secondary)]">
Manual install steps
</div>
<ol className="text-xs text-[var(--text-secondary)] list-decimal list-inside space-y-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2">
{options.manual_steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
</div>
)}
{log.length > 0 && (
<div className="mt-4 max-h-48 overflow-y-auto bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2 text-xs font-mono text-[var(--text-secondary)]">
{log.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
)}
{phase === "idle" && (
<div className="mt-4 flex justify-end">
<button
onClick={onClose}
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Dismiss
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -8,6 +8,9 @@ vi.mock("../../store/appState", () => ({
selector({ selector({
sidebarView: "projects", sidebarView: "projects",
setSidebarView: vi.fn(), setSidebarView: vi.fn(),
sidebarCollapsed: false,
setSidebarCollapsed: vi.fn(),
toggleSidebarCollapsed: vi.fn(),
}) })
), ),
})); }));

View File

@@ -1,15 +1,100 @@
import type { ReactNode } from "react";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import ProjectList from "../projects/ProjectList"; import ProjectList from "../projects/ProjectList";
import McpPanel from "../mcp/McpPanel"; import McpPanel from "../mcp/McpPanel";
import SettingsPanel from "../settings/SettingsPanel"; import SettingsPanel from "../settings/SettingsPanel";
type SidebarView = "projects" | "mcp" | "settings";
const RAIL_ICONS: { view: SidebarView; label: string; icon: ReactNode }[] = [
{
view: "projects",
label: "Projects",
icon: (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" />
</svg>
),
},
{
view: "mcp",
label: "MCP",
icon: (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 2v6" />
<path d="M15 2v6" />
<path d="M7 8h10v4a5 5 0 0 1-10 0V8z" />
<path d="M12 17v5" />
</svg>
),
},
{
view: "settings",
label: "Settings",
icon: (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
),
},
];
export default function Sidebar() { export default function Sidebar() {
const { sidebarView, setSidebarView } = useAppState( const { sidebarView, setSidebarView, sidebarCollapsed, setSidebarCollapsed, toggleSidebarCollapsed } = useAppState(
useShallow(s => ({ sidebarView: s.sidebarView, setSidebarView: s.setSidebarView })) useShallow(s => ({
sidebarView: s.sidebarView,
setSidebarView: s.setSidebarView,
sidebarCollapsed: s.sidebarCollapsed,
setSidebarCollapsed: s.setSidebarCollapsed,
toggleSidebarCollapsed: s.toggleSidebarCollapsed,
}))
); );
const tabCls = (view: typeof sidebarView) => if (sidebarCollapsed) {
const railBtn = (view: SidebarView, label: string, icon: ReactNode) => {
const active = sidebarView === view;
return (
<button
key={view}
onClick={() => {
setSidebarView(view);
setSidebarCollapsed(false);
}}
title={label}
aria-label={label}
className={`flex items-center justify-center h-10 w-full transition-colors ${
active
? "text-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{icon}
</button>
);
};
return (
<div className="flex flex-col h-full w-12 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
<button
onClick={toggleSidebarCollapsed}
title="Expand sidebar"
aria-label="Expand sidebar"
className="flex items-center justify-center h-10 border-b border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<div className="flex flex-col py-1">
{RAIL_ICONS.map(({ view, label, icon }) => railBtn(view, label, icon))}
</div>
</div>
);
}
const tabCls = (view: SidebarView) =>
`flex-1 px-3 py-2 text-sm font-medium transition-colors ${ `flex-1 px-3 py-2 text-sm font-medium transition-colors ${
sidebarView === view sidebarView === view
? "text-[var(--accent)] border-b-2 border-[var(--accent)]" ? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
@@ -29,6 +114,16 @@ export default function Sidebar() {
<button onClick={() => setSidebarView("settings")} className={tabCls("settings")}> <button onClick={() => setSidebarView("settings")} className={tabCls("settings")}>
Settings Settings
</button> </button>
<button
onClick={toggleSidebarCollapsed}
title="Collapse sidebar"
aria-label="Collapse sidebar"
className="px-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</div> </div>
{/* Content */} {/* Content */}

View File

@@ -23,7 +23,9 @@ export default function StatusBar() {
{terminalHasSelection && ( {terminalHasSelection && (
<> <>
<span className="mx-2">|</span> <span className="mx-2">|</span>
<span className="text-[var(--accent)]">Ctrl+Shift+C to copy</span> <span className="text-[var(--accent)]">
Ctrl+Shift+C: copy trimmed &middot; Ctrl+Shift+Alt+C: copy raw
</span>
</> </>
)} )}
</div> </div>

View File

@@ -0,0 +1,191 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { ClaudeCodeSettings } from "../../lib/types";
interface Props {
settings: ClaudeCodeSettings | null;
disabled: boolean;
onSave: (settings: ClaudeCodeSettings | null) => Promise<void>;
onClose: () => void;
}
const DEFAULTS: ClaudeCodeSettings = {
tui_mode: null,
effort: null,
auto_scroll_disabled: false,
focus_mode: false,
show_thinking_summaries: false,
enable_session_recap: false,
env_scrub: false,
prompt_caching_1h: false,
};
function isAllDefaults(s: ClaudeCodeSettings): boolean {
return (
s.tui_mode === null &&
s.effort === null &&
s.auto_scroll_disabled === false &&
s.focus_mode === false &&
s.show_thinking_summaries === false &&
s.enable_session_recap === false &&
s.env_scrub === false &&
s.prompt_caching_1h === false
);
}
export default function ClaudeCodeSettingsModal({ settings, disabled, onSave, onClose }: Props) {
const [local, setLocal] = useState<ClaudeCodeSettings>(settings ?? { ...DEFAULTS });
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current) onClose();
},
[onClose],
);
const update = async (patch: Partial<ClaudeCodeSettings>) => {
const next = { ...local, ...patch };
setLocal(next);
try {
await onSave(isAllDefaults(next) ? null : next);
} catch (err) {
console.error("Failed to save Claude Code settings:", err);
}
};
const toggleButton = (label: string, description: string, value: boolean, onChange: (v: boolean) => void) => (
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--text-primary)]">{label}</div>
<div className="text-xs text-[var(--text-secondary)]">{description}</div>
</div>
<button
onClick={() => onChange(!value)}
disabled={disabled}
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 shrink-0 ${
value
? "bg-[var(--success)] text-white"
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
}`}
>
{value ? "ON" : "OFF"}
</button>
</div>
);
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[32rem] shadow-xl max-h-[80vh] overflow-y-auto">
<h2 className="text-lg font-semibold mb-4">Claude Code Settings</h2>
{disabled && (
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
Container must be stopped to change Claude Code settings.
</div>
)}
<div className="space-y-4 mb-6">
{/* TUI Mode */}
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--text-primary)]">TUI Mode</div>
<div className="text-xs text-[var(--text-secondary)]">Enables flicker-free alt-screen rendering</div>
</div>
<select
value={local.tui_mode ?? ""}
onChange={(e) => update({ tui_mode: e.target.value || null })}
disabled={disabled}
className="px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 shrink-0"
>
<option value="">Default</option>
<option value="fullscreen">Fullscreen</option>
</select>
</div>
{/* Effort Level */}
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--text-primary)]">Effort Level</div>
<div className="text-xs text-[var(--text-secondary)]">Controls how much reasoning Claude applies</div>
</div>
<select
value={local.effort ?? ""}
onChange={(e) => update({ effort: e.target.value || null })}
disabled={disabled}
className="px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 shrink-0"
>
<option value="">Default</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
{/* Boolean toggles */}
{toggleButton(
"Focus Mode",
"Collapses tool output to one-line summaries",
local.focus_mode,
(v) => update({ focus_mode: v }),
)}
{toggleButton(
"Thinking Summaries",
"Shows thinking process as summaries",
local.show_thinking_summaries,
(v) => update({ show_thinking_summaries: v }),
)}
{toggleButton(
"Session Recap",
"Provides context when returning to a session",
local.enable_session_recap,
(v) => update({ enable_session_recap: v }),
)}
{toggleButton(
"Auto-Scroll Disabled",
"Disables auto-scroll when in fullscreen TUI mode",
local.auto_scroll_disabled,
(v) => update({ auto_scroll_disabled: v }),
)}
{toggleButton(
"Env Scrub",
"Strips credentials from subprocess environments for security",
local.env_scrub,
(v) => update({ env_scrub: v }),
)}
{toggleButton(
"Prompt Caching (1h)",
"Enables 1-hour prompt cache TTL instead of 5 minutes",
local.prompt_caching_1h,
(v) => update({ prompt_caching_1h: v }),
)}
</div>
<div className="flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import { useAppState } from "../../store/appState";
import EnvVarsModal from "./EnvVarsModal"; import EnvVarsModal from "./EnvVarsModal";
import PortMappingsModal from "./PortMappingsModal"; import PortMappingsModal from "./PortMappingsModal";
import ClaudeInstructionsModal from "./ClaudeInstructionsModal"; import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
import ClaudeCodeSettingsModal from "./ClaudeCodeSettingsModal";
import ContainerProgressModal from "./ContainerProgressModal"; import ContainerProgressModal from "./ContainerProgressModal";
import FileManagerModal from "./FileManagerModal"; import FileManagerModal from "./FileManagerModal";
import ConfirmRemoveModal from "./ConfirmRemoveModal"; import ConfirmRemoveModal from "./ConfirmRemoveModal";
@@ -30,6 +31,7 @@ export default function ProjectCard({ project }: Props) {
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false); const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false); const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false); const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false);
const [showFileManager, setShowFileManager] = useState(false); const [showFileManager, setShowFileManager] = useState(false);
const [progressMsg, setProgressMsg] = useState<string | null>(null); const [progressMsg, setProgressMsg] = useState<string | null>(null);
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null); const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
@@ -58,6 +60,7 @@ export default function ProjectCard({ project }: Props) {
const [bedrockProfile, setBedrockProfile] = useState(project.bedrock_config?.aws_profile ?? ""); const [bedrockProfile, setBedrockProfile] = useState(project.bedrock_config?.aws_profile ?? "");
const [bedrockBearerToken, setBedrockBearerToken] = useState(project.bedrock_config?.aws_bearer_token ?? ""); const [bedrockBearerToken, setBedrockBearerToken] = useState(project.bedrock_config?.aws_bearer_token ?? "");
const [bedrockModelId, setBedrockModelId] = useState(project.bedrock_config?.model_id ?? ""); const [bedrockModelId, setBedrockModelId] = useState(project.bedrock_config?.model_id ?? "");
const [bedrockServiceTier, setBedrockServiceTier] = useState(project.bedrock_config?.service_tier ?? "");
// Ollama local state // Ollama local state
const [ollamaBaseUrl, setOllamaBaseUrl] = useState(project.ollama_config?.base_url ?? "http://host.docker.internal:11434"); const [ollamaBaseUrl, setOllamaBaseUrl] = useState(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
@@ -86,6 +89,7 @@ export default function ProjectCard({ project }: Props) {
setBedrockProfile(project.bedrock_config?.aws_profile ?? ""); setBedrockProfile(project.bedrock_config?.aws_profile ?? "");
setBedrockBearerToken(project.bedrock_config?.aws_bearer_token ?? ""); setBedrockBearerToken(project.bedrock_config?.aws_bearer_token ?? "");
setBedrockModelId(project.bedrock_config?.model_id ?? ""); setBedrockModelId(project.bedrock_config?.model_id ?? "");
setBedrockServiceTier(project.bedrock_config?.service_tier ?? "");
setOllamaBaseUrl(project.ollama_config?.base_url ?? "http://host.docker.internal:11434"); setOllamaBaseUrl(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
setOllamaModelId(project.ollama_config?.model_id ?? ""); setOllamaModelId(project.ollama_config?.model_id ?? "");
setOpenaiCompatibleBaseUrl(project.openai_compatible_config?.base_url ?? "http://host.docker.internal:4000"); setOpenaiCompatibleBaseUrl(project.openai_compatible_config?.base_url ?? "http://host.docker.internal:4000");
@@ -190,6 +194,7 @@ export default function ProjectCard({ project }: Props) {
aws_bearer_token: null, aws_bearer_token: null,
model_id: null, model_id: null,
disable_prompt_caching: false, disable_prompt_caching: false,
service_tier: null,
}; };
const defaultOllamaConfig: OllamaConfig = { const defaultOllamaConfig: OllamaConfig = {
@@ -337,6 +342,16 @@ export default function ProjectCard({ project }: Props) {
} }
}; };
const handleBedrockServiceTierBlur = async () => {
try {
const current = project.bedrock_config ?? defaultBedrockConfig;
const trimmed = bedrockServiceTier.trim();
await update({ ...project, bedrock_config: { ...current, service_tier: trimmed || null } });
} catch (err) {
console.error("Failed to update Bedrock service tier:", err);
}
};
const handleOllamaBaseUrlBlur = async () => { const handleOllamaBaseUrlBlur = async () => {
try { try {
const current = project.ollama_config ?? defaultOllamaConfig; const current = project.ollama_config ?? defaultOllamaConfig;
@@ -690,6 +705,28 @@ export default function ProjectCard({ project }: Props) {
</button> </button>
</div> </div>
{/* Sandbox mode toggle */}
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--text-secondary)]">Sandbox mode<Tooltip text="Enables Claude Code's bash sandbox (bubblewrap-based filesystem and network isolation). Triple-C is the source of truth for the on/off state — toggling this overrides any manual /sandbox configuration in the container's settings.json on next start. Uses enableWeakerNestedSandbox since the container runs without privileged user namespaces." /></label>
<button
onClick={async () => {
try {
await update({ ...project, sandbox_mode_enabled: !project.sandbox_mode_enabled });
} catch (err) {
console.error("Failed to update sandbox mode setting:", err);
}
}}
disabled={!isStopped}
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
project.sandbox_mode_enabled
? "bg-[var(--success)] text-white"
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
}`}
>
{project.sandbox_mode_enabled ? "ON" : "OFF"}
</button>
</div>
{/* Mission Control toggle */} {/* Mission Control toggle */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-[var(--text-secondary)]">Mission Control<Tooltip text="Enables a web dashboard for monitoring and managing Claude sessions remotely." /></label> <label className="text-xs text-[var(--text-secondary)]">Mission Control<Tooltip text="Enables a web dashboard for monitoring and managing Claude sessions remotely." /></label>
@@ -777,6 +814,19 @@ export default function ProjectCard({ project }: Props) {
</button> </button>
</div> </div>
{/* Claude Code Settings */}
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">
Claude Code Settings{project.claude_code_settings ? " (set)" : ""}<Tooltip text="Configure Claude Code CLI behavior: TUI mode, effort level, focus mode, prompt caching, and more. These override global defaults for this project." />
</label>
<button
onClick={() => setShowClaudeCodeSettingsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
{/* MCP Servers */} {/* MCP Servers */}
{mcpServers.length > 0 && ( {mcpServers.length > 0 && (
<div> <div>
@@ -938,6 +988,19 @@ export default function ProjectCard({ project }: Props) {
className={inputCls} className={inputCls}
/> />
</div> </div>
{/* Service tier */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Service Tier (optional)<Tooltip text="Sets ANTHROPIC_BEDROCK_SERVICE_TIER. Valid values are determined by AWS Bedrock (e.g. 'priority'). Leave blank for the account default." /></label>
<input
value={bedrockServiceTier}
onChange={(e) => setBedrockServiceTier(e.target.value)}
onBlur={handleBedrockServiceTierBlur}
placeholder="(default)"
disabled={!isStopped}
className={inputCls}
/>
</div>
</div> </div>
); );
})()} })()}
@@ -1079,6 +1142,17 @@ export default function ProjectCard({ project }: Props) {
/> />
)} )}
{showClaudeCodeSettingsModal && (
<ClaudeCodeSettingsModal
settings={project.claude_code_settings}
disabled={!isStopped}
onSave={async (ccSettings) => {
await update({ ...project, claude_code_settings: ccSettings });
}}
onClose={() => setShowClaudeCodeSettingsModal(false)}
/>
)}
{showFileManager && ( {showFileManager && (
<FileManagerModal <FileManagerModal
projectId={project.id} projectId={project.id}

View File

@@ -12,6 +12,7 @@ export default function AwsSettings() {
aws_config_path: null, aws_config_path: null,
aws_profile: null, aws_profile: null,
aws_region: null, aws_region: null,
default_model_id: null,
}; };
// Load profiles when component mounts or aws_config_path changes // Load profiles when component mounts or aws_config_path changes
@@ -105,6 +106,18 @@ export default function AwsSettings() {
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]" className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/> />
</div> </div>
{/* Default Model ID */}
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Model ID<Tooltip text="Default Bedrock model ID. Used when a Bedrock project doesn't set its own Model ID." /></span>
<input
type="text"
value={globalAws.default_model_id ?? ""}
onChange={(e) => handleChange("default_model_id", e.target.value)}
placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,53 @@
import { useSettings } from "../../hooks/useSettings";
import Tooltip from "../ui/Tooltip";
export default function OllamaSettings() {
const { appSettings, saveSettings } = useSettings();
const globalOllama = appSettings?.global_ollama ?? {
base_url: null,
default_model_id: null,
};
const handleChange = async (field: "base_url" | "default_model_id", value: string) => {
if (!appSettings) return;
await saveSettings({
...appSettings,
global_ollama: { ...globalOllama, [field]: value || null },
});
};
return (
<div>
<label className="block text-sm font-medium mb-2">Ollama Configuration</label>
<div className="space-y-3 text-sm">
<p className="text-xs text-[var(--text-secondary)]">
Global Ollama defaults. Used when a per-project field is blank.
Changes here require a container rebuild to take effect.
</p>
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Base URL<Tooltip text="URL of your Ollama server. Used when a per-project Ollama base URL is blank." /></span>
<input
type="text"
value={globalOllama.base_url ?? ""}
onChange={(e) => handleChange("base_url", e.target.value)}
placeholder="http://host.docker.internal:11434"
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Model<Tooltip text="Default Ollama model name. Used when a per-project Ollama model is blank." /></span>
<input
type="text"
value={globalOllama.default_model_id ?? ""}
onChange={(e) => handleChange("default_model_id", e.target.value)}
placeholder="qwen3.5:27b"
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { useSettings } from "../../hooks/useSettings";
import Tooltip from "../ui/Tooltip";
export default function OpenAiCompatibleSettings() {
const { appSettings, saveSettings } = useSettings();
const globalOai = appSettings?.global_openai_compatible ?? {
base_url: null,
default_model_id: null,
};
const handleChange = async (field: "base_url" | "default_model_id", value: string) => {
if (!appSettings) return;
await saveSettings({
...appSettings,
global_openai_compatible: { ...globalOai, [field]: value || null },
});
};
return (
<div>
<label className="block text-sm font-medium mb-2">OpenAI Compatible Configuration</label>
<div className="space-y-3 text-sm">
<p className="text-xs text-[var(--text-secondary)]">
Global defaults for any OpenAI-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.).
Used when a per-project field is blank. Changes require a container rebuild.
</p>
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Base URL<Tooltip text="Default OpenAI-compatible endpoint URL. Used when a per-project base URL is blank." /></span>
<input
type="text"
value={globalOai.base_url ?? ""}
onChange={(e) => handleChange("base_url", e.target.value)}
placeholder="http://host.docker.internal:4000"
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Model<Tooltip text="Default model identifier. Used when a per-project model is blank." /></span>
<input
type="text"
value={globalOai.default_model_id ?? ""}
onChange={(e) => handleChange("default_model_id", e.target.value)}
placeholder="gpt-4o / gemini-pro / etc."
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
</div>
</div>
);
}

View File

@@ -1,13 +1,17 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import DockerSettings from "./DockerSettings"; import DockerSettings from "./DockerSettings";
import AwsSettings from "./AwsSettings"; import AwsSettings from "./AwsSettings";
import OllamaSettings from "./OllamaSettings";
import OpenAiCompatibleSettings from "./OpenAiCompatibleSettings";
import { useSettings } from "../../hooks/useSettings"; import { useSettings } from "../../hooks/useSettings";
import { useUpdates } from "../../hooks/useUpdates"; import { useUpdates } from "../../hooks/useUpdates";
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal"; import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
import ClaudeCodeSettingsModal from "../projects/ClaudeCodeSettingsModal";
import EnvVarsModal from "../projects/EnvVarsModal"; import EnvVarsModal from "../projects/EnvVarsModal";
import { detectHostTimezone } from "../../lib/tauri-commands"; import { detectHostTimezone } from "../../lib/tauri-commands";
import type { EnvVar } from "../../lib/types"; import type { EnvVar } from "../../lib/types";
import Tooltip from "../ui/Tooltip"; import Tooltip from "../ui/Tooltip";
import AccordionSection from "../ui/AccordionSection";
import WebTerminalSettings from "./WebTerminalSettings"; import WebTerminalSettings from "./WebTerminalSettings";
import SttSettings from "./SttSettings"; import SttSettings from "./SttSettings";
@@ -18,15 +22,22 @@ export default function SettingsPanel() {
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []); const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
const [checkingUpdates, setCheckingUpdates] = useState(false); const [checkingUpdates, setCheckingUpdates] = useState(false);
const [timezone, setTimezone] = useState(appSettings?.timezone ?? ""); const [timezone, setTimezone] = useState(appSettings?.timezone ?? "");
const [sshKeyPath, setSshKeyPath] = useState(appSettings?.default_ssh_key_path ?? "");
const [gitName, setGitName] = useState(appSettings?.default_git_user_name ?? "");
const [gitEmail, setGitEmail] = useState(appSettings?.default_git_user_email ?? "");
const [showInstructionsModal, setShowInstructionsModal] = useState(false); const [showInstructionsModal, setShowInstructionsModal] = useState(false);
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false); const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false);
// Sync local state when appSettings change // Sync local state when appSettings change
useEffect(() => { useEffect(() => {
setGlobalInstructions(appSettings?.global_claude_instructions ?? ""); setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []); setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []);
setTimezone(appSettings?.timezone ?? ""); setTimezone(appSettings?.timezone ?? "");
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars, appSettings?.timezone]); setSshKeyPath(appSettings?.default_ssh_key_path ?? "");
setGitName(appSettings?.default_git_user_name ?? "");
setGitEmail(appSettings?.default_git_user_email ?? "");
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars, appSettings?.timezone, appSettings?.default_ssh_key_path, appSettings?.default_git_user_name, appSettings?.default_git_user_email]);
// Auto-detect timezone on first load if not yet set // Auto-detect timezone on first load if not yet set
useEffect(() => { useEffect(() => {
@@ -53,80 +64,164 @@ export default function SettingsPanel() {
}; };
return ( return (
<div className="p-4 space-y-6"> <div className="p-4 space-y-3">
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]"> <h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
Settings Settings
</h2> </h2>
<DockerSettings />
<AwsSettings />
{/* Container Timezone */} <AccordionSection id="general" title="General">
<div> {/* Container Timezone */}
<label className="block text-sm font-medium mb-1">Container Timezone<Tooltip text="Sets the timezone inside containers. Affects scheduled task timing and log timestamps." /></label> <div>
<p className="text-xs text-[var(--text-secondary)] mb-1.5"> <label className="block text-sm font-medium mb-1">Container Timezone<Tooltip text="Sets the timezone inside containers. Affects scheduled task timing and log timestamps." /></label>
Timezone for containers affects scheduled task timing (IANA format, e.g. America/New_York) <p className="text-xs text-[var(--text-secondary)] mb-1.5">
</p> Timezone for containers affects scheduled task timing (IANA format, e.g. America/New_York)
<input </p>
type="text" <input
value={timezone} type="text"
onChange={(e) => setTimezone(e.target.value)} value={timezone}
onBlur={async () => { onChange={(e) => setTimezone(e.target.value)}
if (appSettings) { onBlur={async () => {
await saveSettings({ ...appSettings, timezone: timezone || null }); if (appSettings) {
} await saveSettings({ ...appSettings, timezone: timezone || null });
}} }
placeholder="UTC" }}
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]" placeholder="UTC"
/> className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
</div> />
{/* Global Claude Instructions */}
<div>
<label className="block text-sm font-medium mb-1">Claude Instructions<Tooltip text="Global instructions applied to all projects. Written to ~/.claude/CLAUDE.md in every container." /></label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
</p>
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--text-secondary)]">
{globalInstructions ? "Configured" : "Not set"}
</span>
<button
onClick={() => setShowInstructionsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div> </div>
</div>
{/* Global Environment Variables */} {/* Global Claude Instructions */}
<div> <div>
<label className="block text-sm font-medium mb-1">Global Environment Variables<Tooltip text="Env vars injected into all containers. Per-project vars with the same key take precedence." /></label> <label className="block text-sm font-medium mb-1">Claude Instructions<Tooltip text="Global instructions applied to all projects. Written to ~/.claude/CLAUDE.md in every container." /></label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5"> <p className="text-xs text-[var(--text-secondary)] mb-1.5">
Applied to all project containers. Per-project variables override global ones with the same key. Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
</p> </p>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-[var(--text-secondary)]"> <span className="text-xs text-[var(--text-secondary)]">
{globalEnvVars.length > 0 ? `${globalEnvVars.length} variable${globalEnvVars.length === 1 ? "" : "s"}` : "None"} {globalInstructions ? "Configured" : "Not set"}
</span> </span>
<button <button
onClick={() => setShowEnvVarsModal(true)} onClick={() => setShowInstructionsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors" className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
> >
Edit Edit
</button> </button>
</div>
</div> </div>
</div>
{/* Web Terminal */} {/* Global Environment Variables */}
<WebTerminalSettings /> <div>
<label className="block text-sm font-medium mb-1">Global Environment Variables<Tooltip text="Env vars injected into all containers. Per-project vars with the same key take precedence." /></label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Applied to all project containers. Per-project variables override global ones with the same key.
</p>
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--text-secondary)]">
{globalEnvVars.length > 0 ? `${globalEnvVars.length} variable${globalEnvVars.length === 1 ? "" : "s"}` : "None"}
</span>
<button
onClick={() => setShowEnvVarsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
</div>
{/* Speech to Text */} {/* Global Claude Code Settings */}
<SttSettings /> <div>
<label className="block text-sm font-medium mb-1">Claude Code Settings<Tooltip text="Global defaults for Claude Code CLI behavior (TUI mode, effort, focus mode, caching, etc.). Per-project settings override these." /></label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Default Claude Code CLI settings applied to all projects. Per-project settings take precedence.
</p>
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--text-secondary)]">
{appSettings?.global_claude_code_settings ? "Configured" : "Using defaults"}
</span>
<button
onClick={() => setShowClaudeCodeSettingsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
</div>
</AccordionSection>
{/* Updates section */} <AccordionSection id="backends" title="Backends" defaultOpen={false}>
<div> <AwsSettings />
<label className="block text-sm font-medium mb-2">Updates<Tooltip text="Check for new versions of the Triple-C app and container image." /></label> <div className="pt-3 border-t border-[var(--border-color)]" />
<OllamaSettings />
<div className="pt-3 border-t border-[var(--border-color)]" />
<OpenAiCompatibleSettings />
</AccordionSection>
<AccordionSection id="container" title="Container" defaultOpen={false}>
<DockerSettings />
</AccordionSection>
<AccordionSection id="git-ssh" title="Git / SSH" defaultOpen={false}>
{/* Default SSH Key Directory */}
<div>
<label className="block text-sm font-medium mb-1">Default SSH Key Directory<Tooltip text="Global default SSH key directory. Mounted into containers that don't have a per-project SSH path set." /></label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Mounted into all containers unless overridden by a per-project setting.
</p>
<input
type="text"
value={sshKeyPath}
onChange={(e) => setSshKeyPath(e.target.value)}
onBlur={async () => {
if (appSettings) {
await saveSettings({ ...appSettings, default_ssh_key_path: sshKeyPath || null });
}
}}
placeholder="~/.ssh"
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
{/* Default Git Name */}
<div>
<label className="block text-sm font-medium mb-1">Default Git Name<Tooltip text="Sets git user.name inside containers. Per-project Git Name takes precedence." /></label>
<input
type="text"
value={gitName}
onChange={(e) => setGitName(e.target.value)}
onBlur={async () => {
if (appSettings) {
await saveSettings({ ...appSettings, default_git_user_name: gitName || null });
}
}}
placeholder="Your Name"
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
{/* Default Git Email */}
<div>
<label className="block text-sm font-medium mb-1">Default Git Email<Tooltip text="Sets git user.email inside containers. Per-project Git Email takes precedence." /></label>
<input
type="text"
value={gitEmail}
onChange={(e) => setGitEmail(e.target.value)}
onBlur={async () => {
if (appSettings) {
await saveSettings({ ...appSettings, default_git_user_email: gitEmail || null });
}
}}
placeholder="you@example.com"
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
</AccordionSection>
<AccordionSection id="tools" title="Tools" defaultOpen={false}>
<WebTerminalSettings />
<SttSettings />
</AccordionSection>
<AccordionSection id="updates" title="Updates" defaultOpen={false}>
<div className="space-y-2"> <div className="space-y-2">
{appVersion && ( {appVersion && (
<p className="text-xs text-[var(--text-secondary)]"> <p className="text-xs text-[var(--text-secondary)]">
@@ -156,11 +251,11 @@ export default function SettingsPanel() {
{imageUpdateInfo && ( {imageUpdateInfo && (
<div className="flex items-center gap-2 px-3 py-2 text-xs bg-[var(--bg-primary)] border border-[var(--warning,#f59e0b)] rounded"> <div className="flex items-center gap-2 px-3 py-2 text-xs bg-[var(--bg-primary)] border border-[var(--warning,#f59e0b)] rounded">
<span className="inline-block w-2 h-2 rounded-full bg-[var(--warning,#f59e0b)]" /> <span className="inline-block w-2 h-2 rounded-full bg-[var(--warning,#f59e0b)]" />
<span>A newer container image is available. Re-pull the image in Docker settings above to update.</span> <span>A newer container image is available. Re-pull the image in Container settings above to update.</span>
</div> </div>
)} )}
</div> </div>
</div> </AccordionSection>
{showInstructionsModal && ( {showInstructionsModal && (
<ClaudeInstructionsModal <ClaudeInstructionsModal
@@ -189,6 +284,19 @@ export default function SettingsPanel() {
onClose={() => setShowEnvVarsModal(false)} onClose={() => setShowEnvVarsModal(false)}
/> />
)} )}
{showClaudeCodeSettingsModal && (
<ClaudeCodeSettingsModal
settings={appSettings?.global_claude_code_settings ?? null}
disabled={false}
onSave={async (ccSettings) => {
if (appSettings) {
await saveSettings({ ...appSettings, global_claude_code_settings: ccSettings });
}
}}
onClose={() => setShowClaudeCodeSettingsModal(false)}
/>
)}
</div> </div>
); );
} }

View File

@@ -62,7 +62,7 @@ export default function SttButton({ state, error, onToggle, onCancel }: Props) {
}; };
return ( return (
<div className="absolute bottom-1 left-1 z-50 flex items-center gap-2"> <div className="absolute bottom-2 left-2 z-50 flex items-center gap-2">
<div className="relative"> <div className="relative">
<button <button
onClick={handleClick} onClick={handleClick}

View File

@@ -0,0 +1,58 @@
import { useEffect, useRef } from "react";
interface Props {
x: number;
y: number;
onCopyTrimmed: () => void;
onCopyRaw: () => void;
onDismiss: () => void;
}
export default function TerminalContextMenu({ x, y, onCopyTrimmed, onCopyRaw, onDismiss }: Props) {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleOutsideClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onDismiss();
}
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onDismiss();
};
document.addEventListener("mousedown", handleOutsideClick, true);
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("mousedown", handleOutsideClick, true);
document.removeEventListener("keydown", handleKey);
};
}, [onDismiss]);
return (
<div
ref={menuRef}
className="fixed z-[60] min-w-[160px] py-1 rounded-md border border-[#30363d] bg-[#1f2937] shadow-lg text-xs text-[#e6edf3]"
style={{ left: x, top: y }}
onContextMenu={(e) => e.preventDefault()}
>
<button
className="w-full text-left px-3 py-1.5 hover:bg-[#2d3748] cursor-pointer"
onClick={onCopyTrimmed}
>
Copy trimmed
<kbd className="float-right ml-3 px-1 py-0.5 text-[10px] bg-[#0d1117] border border-[#30363d] rounded font-mono">
Ctrl+Shift+C
</kbd>
</button>
<button
className="w-full text-left px-3 py-1.5 hover:bg-[#2d3748] cursor-pointer"
onClick={onCopyRaw}
>
Copy raw
<kbd className="float-right ml-3 px-1 py-0.5 text-[10px] bg-[#0d1117] border border-[#30363d] rounded font-mono">
Ctrl+Shift+Alt+C
</kbd>
</button>
</div>
);
}

View File

@@ -1,7 +1,38 @@
import { useEffect, useRef, useState } from "react";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
import { useProjects } from "../../hooks/useProjects";
interface ContextMenuState {
sessionId: string;
x: number;
y: number;
}
export default function TerminalTabs() { export default function TerminalTabs() {
const { sessions, activeSessionId, setActiveSession, close } = useTerminal(); const { sessions, activeSessionId, setActiveSession, close } = useTerminal();
const { projects, update } = useProjects();
const [menu, setMenu] = useState<ContextMenuState | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState("");
const renameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!menu) return;
const dismiss = () => setMenu(null);
window.addEventListener("click", dismiss);
window.addEventListener("scroll", dismiss, true);
return () => {
window.removeEventListener("click", dismiss);
window.removeEventListener("scroll", dismiss, true);
};
}, [menu]);
useEffect(() => {
if (renamingId) {
renameInputRef.current?.focus();
renameInputRef.current?.select();
}
}, [renamingId]);
if (sessions.length === 0) { if (sessions.length === 0) {
return ( return (
@@ -11,33 +42,160 @@ export default function TerminalTabs() {
); );
} }
const getCustomName = (projectId: string, sessionId: string): string | null => {
const project = projects.find((p) => p.id === projectId);
return project?.renamed_session_names?.[sessionId] ?? null;
};
const startRename = (sessionId: string) => {
const session = sessions.find((s) => s.id === sessionId);
if (!session) return;
const current = getCustomName(session.projectId, sessionId) ?? session.sessionName ?? session.projectName;
setRenameDraft(current);
setRenamingId(sessionId);
setMenu(null);
};
const commitRename = async (sessionId: string) => {
const session = sessions.find((s) => s.id === sessionId);
if (!session) {
setRenamingId(null);
return;
}
const project = projects.find((p) => p.id === session.projectId);
if (!project) {
setRenamingId(null);
return;
}
const trimmed = renameDraft.trim();
const map = { ...(project.renamed_session_names ?? {}) };
if (trimmed) {
map[sessionId] = trimmed;
} else {
delete map[sessionId];
}
try {
await update({ ...project, renamed_session_names: map });
} catch (err) {
console.error("Failed to rename terminal tab:", err);
} finally {
setRenamingId(null);
}
};
const clearCustomName = async (sessionId: string) => {
const session = sessions.find((s) => s.id === sessionId);
if (!session) return;
const project = projects.find((p) => p.id === session.projectId);
if (!project) return;
const map = { ...(project.renamed_session_names ?? {}) };
if (!(sessionId in map)) {
setMenu(null);
return;
}
delete map[sessionId];
try {
await update({ ...project, renamed_session_names: map });
} catch (err) {
console.error("Failed to reset terminal tab name:", err);
} finally {
setMenu(null);
}
};
return ( return (
<div className="flex items-center h-full"> <div className="flex items-center h-full">
{sessions.map((session) => ( {sessions.map((session) => {
<div const customName = getCustomName(session.projectId, session.id);
key={session.id} const baseLabel =
onClick={() => setActiveSession(session.id)} (session.sessionName ?? session.projectName) +
className={`flex items-center gap-2 px-3 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${ (session.sessionType === "bash" ? " (bash)" : "");
activeSessionId === session.id const displayLabel = customName
? "bg-[var(--bg-primary)] text-[var(--text-primary)]" ? `${session.projectName}: ${customName}`
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]" : baseLabel;
}`} const isRenaming = renamingId === session.id;
> return (
<span className="truncate max-w-[120px]"> <div
{session.projectName}{session.sessionType === "bash" ? " (bash)" : ""} key={session.id}
</span> onClick={() => setActiveSession(session.id)}
<button onContextMenu={(e) => {
onClick={(e) => { e.preventDefault();
e.stopPropagation(); setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
close(session.id);
}} }}
className="text-[var(--text-secondary)] hover:text-[var(--error)] transition-colors" onDoubleClick={() => startRename(session.id)}
title="Close terminal" className={`flex items-center gap-2 px-3 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${
activeSessionId === session.id
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
> >
× {isRenaming ? (
</button> <input
</div> ref={renameInputRef}
))} value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onBlur={() => commitRename(session.id)}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
if (e.key === "Escape") setRenamingId(null);
}}
className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded text-xs text-[var(--text-primary)] focus:outline-none"
/>
) : (
<span className="truncate max-w-[200px]" title={displayLabel}>
{displayLabel}
</span>
)}
<button
onClick={(e) => {
e.stopPropagation();
close(session.id);
}}
className="text-[var(--text-secondary)] hover:text-[var(--error)] transition-colors"
title="Close terminal"
>
×
</button>
</div>
);
})}
{menu && (() => {
const session = sessions.find((s) => s.id === menu.sessionId);
const hasCustom = session ? !!getCustomName(session.projectId, menu.sessionId) : false;
return (
<div
className="fixed z-50 min-w-[160px] py-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded shadow-lg text-xs"
style={{ top: menu.y, left: menu.x }}
onClick={(e) => e.stopPropagation()}
>
<button
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-primary)] transition-colors"
onClick={() => startRename(menu.sessionId)}
>
Rename tab
</button>
{hasCustom && (
<button
className="w-full text-left px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--bg-primary)] transition-colors"
onClick={() => clearCustomName(menu.sessionId)}
>
Reset name
</button>
)}
<div className="border-t border-[var(--border-color)] my-1" />
<button
className="w-full text-left px-3 py-1.5 text-[var(--error)] hover:bg-[var(--bg-primary)] transition-colors"
onClick={() => {
close(menu.sessionId);
setMenu(null);
}}
>
Close tab
</button>
</div>
);
})()}
</div> </div>
); );
} }

View File

@@ -12,6 +12,8 @@ import SttButton from "./SttButton";
import { awsSsoRefresh } from "../../lib/tauri-commands"; import { awsSsoRefresh } from "../../lib/tauri-commands";
import { UrlDetector } from "../../lib/urlDetector"; import { UrlDetector } from "../../lib/urlDetector";
import UrlToast from "./UrlToast"; import UrlToast from "./UrlToast";
import { trimSelection } from "./trimSelection";
import TerminalContextMenu from "./TerminalContextMenu";
interface Props { interface Props {
sessionId: string; sessionId: string;
@@ -42,6 +44,7 @@ export default function TerminalView({ sessionId, active }: Props) {
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null); const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true); const [isAtBottom, setIsAtBottom] = useState(true);
const [isAutoFollow, setIsAutoFollow] = useState(true); const [isAutoFollow, setIsAutoFollow] = useState(true);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const isAtBottomRef = useRef(true); const isAtBottomRef = useRef(true);
// Tracks user intent to follow output — only set to false by explicit user // Tracks user intent to follow output — only set to false by explicit user
// actions (mouse wheel up), not by xterm scroll events during writes. // actions (mouse wheel up), not by xterm scroll events during writes.
@@ -93,14 +96,16 @@ export default function TerminalView({ sessionId, active }: Props) {
term.open(containerRef.current); term.open(containerRef.current);
// Ctrl+Shift+C copies selected terminal text to clipboard. // Ctrl+Shift+C copies the selection with whitespace trimmed (UI padding
// This prevents the keystroke from reaching the container (where // stripped, internal indentation preserved). Ctrl+Shift+Alt+C copies raw.
// Ctrl+C would send SIGINT and cancel running work). // Both prevent the keystroke from reaching the container (where Ctrl+C
// would send SIGINT and cancel running work).
term.attachCustomKeyEventHandler((event) => { term.attachCustomKeyEventHandler((event) => {
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "C") { if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "C") {
const sel = term.getSelection(); const sel = term.getSelection();
if (sel) { if (sel) {
navigator.clipboard.writeText(sel).catch((e) => const out = event.altKey ? sel : trimSelection(sel);
navigator.clipboard.writeText(out).catch((e) =>
console.error("Ctrl+Shift+C clipboard write failed:", e), console.error("Ctrl+Shift+C clipboard write failed:", e),
); );
} }
@@ -388,6 +393,23 @@ export default function TerminalView({ sessionId, active }: Props) {
} }
}, []); }, []);
const writeSelection = useCallback((mode: "trimmed" | "raw") => {
const term = termRef.current;
if (!term) return;
const sel = term.getSelection();
if (!sel) return;
const out = mode === "raw" ? sel : trimSelection(sel);
navigator.clipboard.writeText(out).catch((e) =>
console.error("Context menu clipboard write failed:", e),
);
}, []);
const handleContextMenu = useCallback((e: React.MouseEvent) => {
if (!termRef.current?.hasSelection()) return; // let default menu happen
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY });
}, []);
const handleToggleAutoFollow = useCallback(() => { const handleToggleAutoFollow = useCallback(() => {
const next = !autoFollowRef.current; const next = !autoFollowRef.current;
autoFollowRef.current = next; autoFollowRef.current = next;
@@ -449,8 +471,24 @@ export default function TerminalView({ sessionId, active }: Props) {
<div <div
ref={containerRef} ref={containerRef}
className="w-full h-full" className="w-full h-full"
style={{ padding: "8px" }} style={{ padding: "8px 12px 48px 16px" }}
onContextMenu={handleContextMenu}
/> />
{contextMenu && (
<TerminalContextMenu
x={contextMenu.x}
y={contextMenu.y}
onCopyTrimmed={() => {
writeSelection("trimmed");
setContextMenu(null);
}}
onCopyRaw={() => {
writeSelection("raw");
setContextMenu(null);
}}
onDismiss={() => setContextMenu(null)}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import { trimSelection } from "./trimSelection";
describe("trimSelection", () => {
it("returns empty string unchanged", () => {
expect(trimSelection("")).toBe("");
});
it("trims leading and trailing whitespace on a single line", () => {
expect(trimSelection(" hello ")).toBe("hello");
});
it("dedents common leading whitespace while preserving inner indent", () => {
const input = " def foo():\n return 1\n";
expect(trimSelection(input)).toBe("def foo():\n return 1");
});
it("strips leading and trailing blank lines", () => {
const input = "\n\n hello\n\n";
expect(trimSelection(input)).toBe("hello");
});
it("preserves interior blank lines", () => {
const input = " line1\n\n line2";
expect(trimSelection(input)).toBe("line1\n\nline2");
});
it("is idempotent on already-clean text", () => {
const clean = "def foo():\n return 1";
expect(trimSelection(clean)).toBe(clean);
expect(trimSelection(trimSelection(clean))).toBe(clean);
});
it("ignores blank lines when computing the common indent", () => {
// The blank line has 0 leading whitespace but shouldn't force minIndent to 0.
const input = " a\n\n b";
expect(trimSelection(input)).toBe("a\n\nb");
});
it("strips trailing whitespace per line", () => {
const input = "alpha \nbeta\t\t\ngamma";
expect(trimSelection(input)).toBe("alpha\nbeta\ngamma");
});
it("handles mixed-width padding (pads to min)", () => {
const input = " one\n two\n three";
// minIndent = 2
expect(trimSelection(input)).toBe(" one\ntwo\n three");
});
it("handles tabs as leading whitespace", () => {
const input = "\tfoo\n\t\tbar";
// minIndent = 1 tab
expect(trimSelection(input)).toBe("foo\n\tbar");
});
it("returns empty when input is only whitespace", () => {
expect(trimSelection(" \n \n")).toBe("");
});
it("leaves a zero-indent line alone (no false dedent)", () => {
const input = "no-indent\n indented";
expect(trimSelection(input)).toBe("no-indent\n indented");
});
});

View File

@@ -0,0 +1,42 @@
/**
* Cleans up terminal selections for pasting into other tools.
*
* Terminal UI padding (left margin from the xterm container, alignment spaces
* at end of line) ends up in the copied text. This helper removes that cruft
* while preserving the *relative* indentation of the content — so code blocks
* keep their shape but lose the wrapper padding.
*
* Steps:
* 1. Dedent — strip the common leading whitespace count from every line.
* 2. trimEnd — drop trailing whitespace per line.
* 3. Drop fully-blank leading and trailing lines.
*
* Internal newlines and relative indentation are preserved. Pure function.
*/
export function trimSelection(text: string): string {
if (!text) return text;
const lines = text.split("\n");
let minIndent = Infinity;
for (const line of lines) {
if (line.trim() === "") continue;
const match = line.match(/^[ \t]*/);
const indent = match ? match[0].length : 0;
if (indent < minIndent) minIndent = indent;
if (minIndent === 0) break;
}
if (!Number.isFinite(minIndent)) minIndent = 0;
const processed = lines.map((line) => {
const afterDedent = line.length >= minIndent ? line.slice(minIndent) : "";
return afterDedent.trimEnd();
});
let start = 0;
let end = processed.length;
while (start < end && processed[start] === "") start++;
while (end > start && processed[end - 1] === "") end--;
return processed.slice(start, end).join("\n");
}

View File

@@ -0,0 +1,65 @@
import { useEffect, useState, type ReactNode } from "react";
interface Props {
id: string;
title: string;
defaultOpen?: boolean;
storageKey?: string;
children: ReactNode;
}
function loadOpenState(storageKey: string, fallback: boolean): boolean {
try {
const stored = localStorage.getItem(storageKey);
if (stored === null) return fallback;
return stored === "1";
} catch {
return fallback;
}
}
function persistOpenState(storageKey: string, open: boolean) {
try {
localStorage.setItem(storageKey, open ? "1" : "0");
} catch {
// ignore
}
}
export default function AccordionSection({ id, title, defaultOpen = true, storageKey, children }: Props) {
const key = storageKey ?? `triple-c.accordion.${id}`;
const [open, setOpen] = useState<boolean>(() => loadOpenState(key, defaultOpen));
useEffect(() => {
persistOpenState(key, open);
}, [key, open]);
return (
<div className="border border-[var(--border-color)] rounded">
<button
type="button"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
className="w-full flex items-center justify-between px-3 py-2 text-left text-sm font-medium text-[var(--text-primary)] hover:bg-[var(--bg-primary)] transition-colors"
>
<span>{title}</span>
<svg
className={`w-4 h-4 text-[var(--text-secondary)] transition-transform ${open ? "rotate-90" : ""}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
{open && (
<div className="px-3 py-3 border-t border-[var(--border-color)] space-y-4">
{children}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { useCallback, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import * as commands from "../lib/tauri-commands";
import type { InstallOptions } from "../lib/types";
export function useInstallHelper() {
const [options, setOptions] = useState<InstallOptions | null>(null);
const loadOptions = useCallback(async () => {
try {
const opts = await commands.detectInstallOptions();
setOptions(opts);
return opts;
} catch {
setOptions(null);
return null;
}
}, []);
const runInstall = useCallback(
async (onProgress?: (line: string) => void) => {
const unlisten = onProgress
? await listen<string>("docker-install-progress", (e) => onProgress(e.payload))
: null;
try {
await commands.runDockerInstall();
} finally {
unlisten?.();
}
},
[],
);
return { options, loadOptions, runInstall };
}

View File

@@ -17,10 +17,10 @@ export function useTerminal() {
); );
const open = useCallback( const open = useCallback(
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude") => { async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude", sessionName?: string) => {
const sessionId = crypto.randomUUID(); const sessionId = crypto.randomUUID();
await commands.openTerminalSession(projectId, sessionId, sessionType); await commands.openTerminalSession(projectId, sessionId, sessionType, sessionName);
addSession({ id: sessionId, projectId, projectName, sessionType }); addSession({ id: sessionId, projectId, projectName, sessionType, sessionName: sessionName ?? null });
return sessionId; return sessionId;
}, },
[addSession], [addSession],
@@ -28,8 +28,25 @@ export function useTerminal() {
const close = useCallback( const close = useCallback(
async (sessionId: string) => { async (sessionId: string) => {
// Capture session/project info before we drop it from local state.
const { sessions: currentSessions, projects } = useAppState.getState();
const session = currentSessions.find((s) => s.id === sessionId);
const project = session ? projects.find((p) => p.id === session.projectId) : undefined;
await commands.closeTerminalSession(sessionId); await commands.closeTerminalSession(sessionId);
removeSession(sessionId); removeSession(sessionId);
// Drop any persisted custom name for this session.
if (project && project.renamed_session_names && sessionId in project.renamed_session_names) {
const map = { ...project.renamed_session_names };
delete map[sessionId];
try {
const updated = await commands.updateProject({ ...project, renamed_session_names: map });
useAppState.getState().updateProjectInList(updated);
} catch (err) {
console.error("Failed to clear renamed tab name on close:", err);
}
}
}, },
[removeSession], [removeSession],
); );

View File

@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry, WebTerminalInfo, SttStatus } from "./types"; import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry, WebTerminalInfo, SttStatus, InstallOptions } from "./types";
// Docker // Docker
export const checkDocker = () => invoke<boolean>("check_docker"); export const checkDocker = () => invoke<boolean>("check_docker");
@@ -45,8 +45,8 @@ export const awsSsoRefresh = (projectId: string) =>
invoke<void>("aws_sso_refresh", { projectId }); invoke<void>("aws_sso_refresh", { projectId });
// Terminal // Terminal
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string) => export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string, sessionName?: string) =>
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType }); invoke<void>("open_terminal_session", { projectId, sessionId, sessionType, sessionName });
export const terminalInput = (sessionId: string, data: number[]) => export const terminalInput = (sessionId: string, data: number[]) =>
invoke<void>("terminal_input", { sessionId, data }); invoke<void>("terminal_input", { sessionId, data });
export const terminalResize = (sessionId: string, cols: number, rows: number) => export const terminalResize = (sessionId: string, cols: number, rows: number) =>
@@ -107,3 +107,8 @@ export const buildSttImage = () => invoke<void>("build_stt_image");
export const pullSttImage = () => invoke<void>("pull_stt_image"); export const pullSttImage = () => invoke<void>("pull_stt_image");
export const transcribeAudio = (audioData: number[]) => export const transcribeAudio = (audioData: number[]) =>
invoke<string>("transcribe_audio", { audioData }); invoke<string>("transcribe_audio", { audioData });
// Docker install helper
export const detectInstallOptions = () =>
invoke<InstallOptions>("detect_install_options");
export const runDockerInstall = () => invoke<void>("run_docker_install");

View File

@@ -25,6 +25,7 @@ export interface Project {
ollama_config: OllamaConfig | null; ollama_config: OllamaConfig | null;
openai_compatible_config: OpenAiCompatibleConfig | null; openai_compatible_config: OpenAiCompatibleConfig | null;
allow_docker_access: boolean; allow_docker_access: boolean;
sandbox_mode_enabled: boolean;
mission_control_enabled: boolean; mission_control_enabled: boolean;
full_permissions: boolean; full_permissions: boolean;
ssh_key_path: string | null; ssh_key_path: string | null;
@@ -35,6 +36,8 @@ export interface Project {
port_mappings: PortMapping[]; port_mappings: PortMapping[];
claude_instructions: string | null; claude_instructions: string | null;
enabled_mcp_servers: string[]; enabled_mcp_servers: string[];
claude_code_settings: ClaudeCodeSettings | null;
renamed_session_names: Record<string, string>;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -60,6 +63,7 @@ export interface BedrockConfig {
aws_bearer_token: string | null; aws_bearer_token: string | null;
model_id: string | null; model_id: string | null;
disable_prompt_caching: boolean; disable_prompt_caching: boolean;
service_tier: string | null;
} }
export interface OllamaConfig { export interface OllamaConfig {
@@ -73,6 +77,17 @@ export interface OpenAiCompatibleConfig {
model_id: string | null; model_id: string | null;
} }
export interface ClaudeCodeSettings {
tui_mode: string | null;
effort: string | null;
auto_scroll_disabled: boolean;
focus_mode: boolean;
show_thinking_summaries: boolean;
enable_session_recap: boolean;
env_scrub: boolean;
prompt_caching_1h: boolean;
}
export interface ContainerInfo { export interface ContainerInfo {
container_id: string; container_id: string;
project_id: string; project_id: string;
@@ -93,6 +108,7 @@ export interface TerminalSession {
projectId: string; projectId: string;
projectName: string; projectName: string;
sessionType: "claude" | "bash"; sessionType: "claude" | "bash";
sessionName: string | null;
} }
export type ImageSource = "registry" | "local_build" | "custom"; export type ImageSource = "registry" | "local_build" | "custom";
@@ -101,6 +117,17 @@ export interface GlobalAwsSettings {
aws_config_path: string | null; aws_config_path: string | null;
aws_profile: string | null; aws_profile: string | null;
aws_region: string | null; aws_region: string | null;
default_model_id: string | null;
}
export interface GlobalOllamaSettings {
base_url: string | null;
default_model_id: string | null;
}
export interface GlobalOpenAiCompatibleSettings {
base_url: string | null;
default_model_id: string | null;
} }
export interface AppSettings { export interface AppSettings {
@@ -111,6 +138,8 @@ export interface AppSettings {
image_source: ImageSource; image_source: ImageSource;
custom_image_name: string | null; custom_image_name: string | null;
global_aws: GlobalAwsSettings; global_aws: GlobalAwsSettings;
global_ollama: GlobalOllamaSettings;
global_openai_compatible: GlobalOpenAiCompatibleSettings;
global_claude_instructions: string | null; global_claude_instructions: string | null;
global_custom_env_vars: EnvVar[]; global_custom_env_vars: EnvVar[];
auto_check_updates: boolean; auto_check_updates: boolean;
@@ -120,6 +149,7 @@ export interface AppSettings {
dismissed_image_digest: string | null; dismissed_image_digest: string | null;
web_terminal: WebTerminalSettings; web_terminal: WebTerminalSettings;
stt: SttSettings; stt: SttSettings;
global_claude_code_settings: ClaudeCodeSettings | null;
} }
export interface SttSettings { export interface SttSettings {
@@ -197,3 +227,14 @@ export interface FileEntry {
modified: string; modified: string;
permissions: string; permissions: string;
} }
export interface InstallOptions {
os: "linux" | "macos" | "windows" | "unknown";
product_name: string;
can_auto_install: boolean;
auto_install_method: string | null;
auto_install_blocker: string | null;
docs_url: string;
manual_steps: string[];
post_install_notes: string[];
}

View File

@@ -1,6 +1,24 @@
import { create } from "zustand"; import { create } from "zustand";
import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer } from "../lib/types"; import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer } from "../lib/types";
const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed";
function loadSidebarCollapsed(): boolean {
try {
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
} catch {
return false;
}
}
function persistSidebarCollapsed(value: boolean) {
try {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, value ? "1" : "0");
} catch {
// ignore — storage may be unavailable
}
}
interface AppState { interface AppState {
// Projects // Projects
projects: Project[]; projects: Project[];
@@ -28,6 +46,9 @@ interface AppState {
setTerminalHasSelection: (has: boolean) => void; setTerminalHasSelection: (has: boolean) => void;
sidebarView: "projects" | "mcp" | "settings"; sidebarView: "projects" | "mcp" | "settings";
setSidebarView: (view: "projects" | "mcp" | "settings") => void; setSidebarView: (view: "projects" | "mcp" | "settings") => void;
sidebarCollapsed: boolean;
setSidebarCollapsed: (collapsed: boolean) => void;
toggleSidebarCollapsed: () => void;
dockerAvailable: boolean | null; dockerAvailable: boolean | null;
setDockerAvailable: (available: boolean | null) => void; setDockerAvailable: (available: boolean | null) => void;
imageExists: boolean | null; imageExists: boolean | null;
@@ -106,6 +127,17 @@ export const useAppState = create<AppState>((set) => ({
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }), setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
sidebarView: "projects", sidebarView: "projects",
setSidebarView: (view) => set({ sidebarView: view }), setSidebarView: (view) => set({ sidebarView: view }),
sidebarCollapsed: loadSidebarCollapsed(),
setSidebarCollapsed: (collapsed) => {
persistSidebarCollapsed(collapsed);
set({ sidebarCollapsed: collapsed });
},
toggleSidebarCollapsed: () =>
set((state) => {
const next = !state.sidebarCollapsed;
persistSidebarCollapsed(next);
return { sidebarCollapsed: next };
}),
dockerAvailable: null, dockerAvailable: null,
setDockerAvailable: (available) => set({ dockerAvailable: available }), setDockerAvailable: (available) => set({ dockerAvailable: available }),
imageExists: null, imageExists: null,

View File

@@ -5,7 +5,17 @@ FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
# ── System packages ────────────────────────────────────────────────────────── # ── System packages ──────────────────────────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \ # The shell retry loop handles transient mirror-sync failures where
# archive.ubuntu.com returns stale Packages.gz files with mismatched hashes
# during hourly resyncs. Clearing /var/lib/apt/lists/* between attempts
# forces a fresh fetch.
RUN for i in 1 2 3 4 5; do \
apt-get -o Acquire::Retries=3 update && break; \
echo "apt-get update failed (attempt $i), retrying in 10s..."; \
rm -rf /var/lib/apt/lists/*; \
sleep 10; \
done \
&& apt-get install -y --no-install-recommends \
git \ git \
curl \ curl \
wget \ wget \
@@ -21,6 +31,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \ pkg-config \
libssl-dev \ libssl-dev \
cron \ cron \
bubblewrap \
socat \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Remove default ubuntu user to free UID 1000 for host-user remapping # Remove default ubuntu user to free UID 1000 for host-user remapping
@@ -38,17 +50,42 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \ && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \ > /etc/apt/sources.list.d/github-cli.list \
&& apt-get update && apt-get install -y gh \ && for i in 1 2 3 4 5; do \
apt-get -o Acquire::Retries=3 update && break; \
echo "apt-get update failed (attempt $i), retrying in 10s..."; \
rm -rf /var/lib/apt/lists/*; \
sleep 10; \
done \
&& apt-get install -y gh \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# ── Node.js LTS (22.x) + pnpm ─────────────────────────────────────────────── # ── Node.js LTS (22.x) + pnpm ───────────────────────────────────────────────
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ # Configure NodeSource repo manually (not via their setup_22.x script, which
# runs an internal apt-get update without retries and silently falls through
# to Ubuntu's default nodejs 18 — missing npm — on mirror-sync failures).
RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /usr/share/keyrings/nodesource.gpg \
&& chmod a+r /usr/share/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& for i in 1 2 3 4 5; do \
apt-get -o Acquire::Retries=3 update && break; \
echo "apt-get update failed (attempt $i), retrying in 10s..."; \
rm -rf /var/lib/apt/lists/*; \
sleep 10; \
done \
&& apt-get install -y nodejs \ && apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& npm install -g pnpm && npm install -g pnpm
# ── Python 3 + pip + uv + ruff ────────────────────────────────────────────── # ── Python 3 + pip + uv + ruff ──────────────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN for i in 1 2 3 4 5; do \
apt-get -o Acquire::Retries=3 update && break; \
echo "apt-get update failed (attempt $i), retrying in 10s..."; \
rm -rf /var/lib/apt/lists/*; \
sleep 10; \
done \
&& apt-get install -y --no-install-recommends \
python3 \ python3 \
python3-pip \ python3-pip \
python3-venv \ python3-venv \
@@ -61,7 +98,13 @@ RUN install -m 0755 -d /etc/apt/keyrings \
&& chmod a+r /etc/apt/keyrings/docker.gpg \ && chmod a+r /etc/apt/keyrings/docker.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list \ > /etc/apt/sources.list.d/docker.list \
&& apt-get update && apt-get install -y docker-ce-cli \ && for i in 1 2 3 4 5; do \
apt-get -o Acquire::Retries=3 update && break; \
echo "apt-get update failed (attempt $i), retrying in 10s..."; \
rm -rf /var/lib/apt/lists/*; \
sleep 10; \
done \
&& apt-get install -y docker-ce-cli \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# ── AWS CLI v2 ─────────────────────────────────────────────────────────────── # ── AWS CLI v2 ───────────────────────────────────────────────────────────────

View File

@@ -188,6 +188,29 @@ if [ -n "$MCP_SERVERS_JSON" ]; then
unset MCP_SERVERS_JSON unset MCP_SERVERS_JSON
fi fi
# ── Claude Code settings ────────────────────────────────────────────────────
# Merge Claude Code settings into ~/.claude/settings.json (preserves existing
# keys). Creates the file if it doesn't exist. These control TUI mode, effort
# level, focus mode, thinking summaries, and other CLI behavior.
if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then
SETTINGS_FILE="/home/claude/.claude/settings.json"
mkdir -p /home/claude/.claude
if [ -f "$SETTINGS_FILE" ]; then
# Merge: existing settings + new settings (new keys override on conflict)
MERGED=$(jq -s '.[0] * .[1]' "$SETTINGS_FILE" <(printf '%s' "$CLAUDE_CODE_SETTINGS_JSON") 2>/dev/null)
if [ -n "$MERGED" ]; then
printf '%s\n' "$MERGED" > "$SETTINGS_FILE"
else
echo "entrypoint: warning — failed to merge Claude Code settings into $SETTINGS_FILE"
fi
else
printf '%s\n' "$CLAUDE_CODE_SETTINGS_JSON" > "$SETTINGS_FILE"
fi
chown claude:claude "$SETTINGS_FILE"
chmod 600 "$SETTINGS_FILE"
unset CLAUDE_CODE_SETTINGS_JSON
fi
# ── AWS SSO auth refresh command ────────────────────────────────────────────── # ── AWS SSO auth refresh command ──────────────────────────────────────────────
# When set, inject awsAuthRefresh into ~/.claude.json so Claude Code calls # When set, inject awsAuthRefresh into ~/.claude.json so Claude Code calls
# triple-c-sso-refresh when AWS credentials expire mid-session. # triple-c-sso-refresh when AWS credentials expire mid-session.

View File

@@ -1,11 +1,11 @@
--- ---
name: agentic-workflow name: agentic-workflow
description: Active orchestrator for multi-agent flight execution. Drives the full leg cycle (design, implement, review, commit) using three separate Claude instances. description: Active orchestrator for multi-agent flight execution. Drives leg design per leg, then batches implementation across all autonomous legs, with a single code review and commit at the end of the flight.
--- ---
# Agentic Workflow # Agentic Workflow
Orchestrate multi-agent flight execution. You drive the full leg cycle — designing legs, spawning Developer and Reviewer agents, and managing git workflow — for a target project's flight. Orchestrate multi-agent flight execution. You drive the full leg cycle — designing legs, spawning Developer and Reviewer agents, and managing git workflow — for a target project's flight. Leg design is reviewed per leg, but code review and commit are deferred until after the last autonomous leg completes. This eliminates per-leg review/commit overhead while keeping the same leg design and implementation structure.
## Prerequisites ## Prerequisites
@@ -33,8 +33,6 @@ Example: `/agentic-workflow flight 03 for epipen mission 04`
6. **Read the flight log** — ground truth from prior execution 6. **Read the flight log** — ground truth from prior execution
7. **Count total legs** from the flight spec — track progress throughout 7. **Count total legs** from the flight spec — track progress throughout
8. **Determine starting point** — which leg is next based on flight log and leg statuses 8. **Determine starting point** — which leg is next based on flight log and leg statuses
9. **Read git strategy** from `{target-project}/.flightops/ARTIFACTS.md` `## Git Workflow` section. Default to `branch` if the section is absent.
10. **Set `{working-directory}`**`branch`: the target project root; `worktree`: the worktree path (see Git Workflow section below)
**Mark flight as in-flight**: After loading the flight artifact, if the flight status is `ready`, update it to `in-flight` before proceeding. If already `in-flight`, leave it as-is. **Mark flight as in-flight**: After loading the flight artifact, if the flight status is `ready`, update it to `in-flight` before proceeding. If already `in-flight`, leave it as-is.
@@ -46,13 +44,15 @@ If resuming a flight already in progress, verify state consistency:
Repeat for each leg in the flight. Repeat for each leg in the flight.
**Mid-execution scope changes**: if the work in this flight stops serving its original purpose (operator pivots, prior assumptions invalidated), don't rewrite the mission/flight artifacts in place. Preserve the original framing as commentary, record the pivot decision in the flight-log Flight Director Notes with rationale, and treat the new framing as the live spec going forward. If the pivot supersedes content in an upstream artifact (maintenance report, prior debrief), annotate at the artifact header rather than rewriting the body — inspection records are snapshots, not living plans.
### 2a: Leg Design ### 2a: Leg Design
1. **Design the leg** using the `/leg` skill (if the Skill tool is unavailable, read `.claude/skills/leg/SKILL.md` and follow the workflow directly) 1. **Design the leg** using the `/leg` skill (if the Skill tool is unavailable, read `.claude/skills/leg/SKILL.md` and follow the workflow directly)
- Read the flight spec, flight log, and relevant source code - Read the flight spec, flight log, and relevant source code
- Create the leg artifact with acceptance criteria - Create the leg artifact with acceptance criteria
2. **Spawn a Developer agent for design review** (Task tool, `subagent_type: "general-purpose"`) 2. **Spawn a Developer agent for design review** (Task tool, `subagent_type: "general-purpose"`)
- Working directory: `{working-directory}` - Working directory: `{target-project}`
- Provide the "Review Leg Design" prompt from the leg-execution phase file's Prompts section - Provide the "Review Leg Design" prompt from the leg-execution phase file's Prompts section
- The Developer reads the leg artifact and cross-references against actual codebase state - The Developer reads the leg artifact and cross-references against actual codebase state
- The Developer provides a structured assessment: approve, approve with changes, or needs rework - The Developer provides a structured assessment: approve, approve with changes, or needs rework
@@ -71,41 +71,42 @@ Repeat for each leg in the flight.
**NEVER implement code directly.** Spawn a Developer agent via the Task tool. **NEVER implement code directly.** Spawn a Developer agent via the Task tool.
**Interactive/UAT legs**: If the leg is a UAT, alignment, or other interactive leg (identified by slug like `uat-*`, `alignment-*`, or explicit marking in the flight spec), do NOT spawn agents to execute it autonomously. The human performs verification — the Flight Director guides them through it: **Interactive/HAT legs**: If the leg is a HAT (human acceptance test), alignment, or other interactive leg (identified by slug like `hat-*`, `alignment-*`, or explicit marking in the flight spec), do NOT spawn agents to execute it autonomously. The human performs verification — the Flight Director guides them through it:
1. **Design the leg** normally (2a), but keep it lightweight — the acceptance criteria are verification steps, not implementation tasks 1. **Design the leg** normally (2a), but keep it lightweight — the acceptance criteria are verification steps, not implementation tasks
2. **Skip the autonomous implementation cycle** (no Developer/Reviewer agents) 2. **Skip the autonomous implementation cycle** (no Developer/Reviewer agents)
3. **Guide the human through verification steps one at a time** — present a single step, wait for the human to perform it and report results, then proceed to the next step 3. **Guide the human through verification steps one at a time** — present a single step, wait for the human to perform it and report results, then proceed to the next step
4. **Fix issues inline** — if the human reports a failure, diagnose and fix it (spawning a Developer agent if code changes are needed), then re-verify that step before moving on 4. **Fix issues inline** — if the human reports a failure, diagnose and fix it (spawning a Developer agent if code changes are needed), then re-verify that step before moving on
5. **Commit when all steps pass** spawn a Developer agent to update artifacts and commit 5. **Commit when all steps pass** — update artifacts and commit
**Standard (autonomous) legs**: Follow the Developer/Reviewer cycle below. **Standard (autonomous) legs**: Spawn a Developer agent — but do NOT review or commit after each leg.
1. **Spawn a Developer agent** (Task tool, `subagent_type: "general-purpose"`) 1. **Spawn a Developer agent** (Task tool, `subagent_type: "general-purpose"`)
- Working directory: `{working-directory}` - Working directory: `{target-project}`
- Provide the "Implement" prompt from the leg-execution phase file's Prompts section - Provide the "Implement" prompt from the leg-execution phase file's Prompts section
- The Developer updates leg status to `in-flight`, implements to acceptance criteria - The Developer updates leg status to `in-flight`, implements to acceptance criteria
- When done, the Developer updates leg status to `landed`, updates flight log, and signals `[HANDOFF:review-needed]` — do NOT let it commit - When done, the Developer updates leg status to `landed` and updates flight log — do NOT let it commit or signal `[HANDOFF:review-needed]`
2. **Spawn a Reviewer agent** (Task tool, `subagent_type: "general-purpose"`)
- Working directory: `{working-directory}`
- Provide the "Review" prompt from the leg-execution phase file's Prompts section
- The Reviewer evaluates ALL uncommitted changes against acceptance criteria and code quality
- The Reviewer signals `[HANDOFF:confirmed]` or lists issues with severity
3. **If issues found**, spawn a new Developer agent to fix them
- Provide the "Fix Review Issues" prompt from the leg-execution phase file with the Reviewer's feedback
- Loop review/fix until the Reviewer confirms
4. **Spawn the Developer agent to commit** after review passes
- Provide the "Commit" prompt from the leg-execution phase file's Prompts section
- The commit must include code changes, updated flight log, and leg status updated to `completed`
### 2c: Leg Transition ### 2c: Leg Transition
After `[COMPLETE:leg]` (all git/PR operations run from `{working-directory}`): After the Developer completes a leg:
1. Increment `legs_completed` 1. Increment `legs_completed`
2. **Manage PR**: 2. If more autonomous legs remain → return to 2a
- **First leg**: Open a draft PR with the leg checklist in the body (see PR Body Format below), then check off the completed leg 3. If this was the last autonomous leg → proceed to Phase 2d
- **Subsequent legs**: Use `gh pr edit --body` to check off the newly completed leg in the existing PR body
3. If more legs remain → return to 2a ### 2d: Flight Review and Commit
4. If all legs complete → proceed to Phase 3
After all autonomous legs are implemented (all uncommitted):
1. **Spawn a Reviewer agent** (Task tool, `subagent_type: "general-purpose"`)
- Working directory: `{target-project}`
- Provide the "Review" prompt from the leg-execution phase file's Prompts section
- The Reviewer evaluates ALL uncommitted changes against acceptance criteria and code quality
- The Reviewer signals `[HANDOFF:confirmed]` or lists issues with severity
2. **If issues found**, spawn a new Developer agent to fix them
- Provide the "Fix Review Issues" prompt from the leg-execution phase file with the Reviewer's feedback
- Loop review/fix until the Reviewer confirms
3. **Commit** after review passes — include all code changes, updated flight log, and all leg statuses updated to `completed`
4. **Manage PR**: Open a draft PR with the leg checklist in the body (see PR Body Format below), all legs checked off
## Phase 3: Flight Completion ## Phase 3: Flight Completion
@@ -114,8 +115,7 @@ After `[COMPLETE:leg]` (all git/PR operations run from `{working-directory}`):
3. **Verify documentation** — check that CLAUDE.md, README, and other project docs reflect any new commands, endpoints, configuration, or APIs introduced during the flight. If not, spawn a Developer agent to update them. 3. **Verify documentation** — check that CLAUDE.md, README, and other project docs reflect any new commands, endpoints, configuration, or APIs introduced during the flight. If not, spawn a Developer agent to update them.
4. **Update flight status** to `landed` 4. **Update flight status** to `landed`
5. **Check off flight** in mission artifact 5. **Check off flight** in mission artifact
6. **Clean up worktree** (worktree strategy only) — run `git worktree remove` after the PR is marked ready for review 6. **Signal `[COMPLETE:flight]`**
7. **Signal `[COMPLETE:flight]`**
The flight debrief is a separate step run via `/flight-debrief` after the flight lands. The debrief transitions the flight to `completed`. The flight debrief is a separate step run via `/flight-debrief` after the flight lands. The debrief transitions the flight to `completed`.
@@ -141,33 +141,20 @@ Signals are part of the Flight Control methodology and are NOT configurable per-
## Flight Director Decision Log ## Flight Director Decision Log
The Flight Director must maintain transparency about its own decisions. After each major orchestration step, log what happened and why in the flight log under a `### Flight Director Notes` subsection: Log orchestration decisions in the flight log under `### Flight Director Notes` — phase file loaded, agents spawned, review-cycle calls, escalations, signal interpretations. Anyone reading the log should understand not just what the crew did but why MC made the choices it did.
1. **Phase file loading** — Record which phase file was loaded (project or default fallback) and what crew was extracted
2. **Agent spawning** — Record which agent was spawned, with what prompt, and what model
3. **Review cycle decisions** — When incorporating feedback, note what was accepted/rejected and why
4. **Escalation decisions** — When choosing between "fix and re-review" vs "escalate to human," note the reasoning
5. **Signal interpretation** — When a crew agent's output is ambiguous, note how it was interpreted
This is not a separate file — it goes in the flight log alongside leg entries. The goal is that anyone reviewing the flight log can understand not just what the crew did, but why the Flight Director made the orchestration choices it did.
## Git Workflow ## Git Workflow
### Strategy Selection All agents work in the target project root on a feature branch created at flight start.
Read the `## Git Workflow` section from `{target-project}/.flightops/ARTIFACTS.md`. The `Strategy` property determines which workflow to use. If the section is absent, default to `branch`.
### Shared Elements
Both strategies use the same branch naming, commit format, PR lifecycle, and PR body format.
**Branch naming**: `flight/{number}-{slug}` **Branch naming**: `flight/{number}-{slug}`
**Flight start**: `git checkout -b flight/{number}-{slug}`
**Commit message format:** **Commit message format:**
``` ```
leg/{number}: {description} flight/{number}: {description}
Flight: {flight-number}
Mission: {mission-number} Mission: {mission-number}
``` ```
@@ -175,8 +162,7 @@ Mission: {mission-number}
| Event | Action | | Event | Action |
|-------|--------| |-------|--------|
| First leg complete | Open draft PR with leg checklist in body | | All legs complete | Open draft PR with all legs checked off |
| Each leg complete | Commit code + artifacts, update PR checklist |
| Flight landed | Mark PR ready for review | | Flight landed | Mark PR ready for review |
**PR body format:** **PR body format:**
@@ -190,35 +176,10 @@ Mission: {mission-number}
## Legs ## Legs
- [ ] `{leg-slug}` — {brief description} - [x] `{leg-slug}` — {brief description}
- [ ] `{leg-slug}` — {brief description} - [x] `{leg-slug}` — {brief description}
``` ```
### Strategy: Branch
The default single-checkout workflow. One flight at a time per working copy.
| Step | Command |
|------|---------|
| Flight start | `git checkout -b flight/{number}-{slug}` |
| Set `{working-directory}` | Target project root |
| Agents work in | Project root |
| Flight landed | PR marked ready for review |
### Strategy: Worktree
Worktree isolation enables parallel flights on a single repo clone.
| Step | Command |
|------|---------|
| Flight start | `git worktree add .worktrees/flight-{number}-{slug} -b flight/{number}-{slug}` |
| Set `{working-directory}` | `.worktrees/flight-{number}-{slug}` |
| Orchestrator stays on | Main branch (does not checkout the flight branch) |
| Agents work in | Worktree path |
| Flight landed | PR marked ready for review, then `git worktree remove .worktrees/flight-{number}-{slug}` |
**Note:** The `.worktrees/` directory must be in `.gitignore` when using this strategy.
## Error Handling ## Error Handling
| Situation | Action | | Situation | Action |
@@ -229,5 +190,4 @@ Worktree isolation enables parallel flights on a single repo clone.
| Leg marked aborted | Escalate to human with abort details | | Leg marked aborted | Escalate to human with abort details |
| Artifact discrepancy | Remediate before proceeding | | Artifact discrepancy | Remediate before proceeding |
| Off the rails | Roll back to last leg commit, escalate | | Off the rails | Roll back to last leg commit, escalate |
| Stale worktree (worktree strategy) | Run `git worktree prune`, recreate if needed |
| Agent hangs on tests | Kill the agent, spawn new Developer to isolate and fix hanging tests | | Agent hangs on tests | Kill the agent, spawn new Developer to isolate and fix hanging tests |

View File

@@ -52,6 +52,7 @@ Read `{target-project}/.flightops/agent-crews/flight-debrief.md` for crew defini
1. **Spawn a Developer agent** in the target project context (Task tool, `subagent_type: "general-purpose"`) 1. **Spawn a Developer agent** in the target project context (Task tool, `subagent_type: "general-purpose"`)
- Provide the "Debrief Interview" prompt from the flight-debrief phase file's Prompts section - Provide the "Debrief Interview" prompt from the flight-debrief phase file's Prompts section
- The Developer examines code changes, test coverage, patterns, and technical debt - The Developer examines code changes, test coverage, patterns, and technical debt
- In addition to the crew prompt, the Flight Director directly instructs the Developer to: run the full test suite once, capture per-suite wall-clock timing, pass/fail counts (with names of any failing tests), skipped/ignored counts, and any flakes observed on the run; then read prior flight debriefs in the project and compare current metrics against any earlier numbers they find. Report findings as narrative — naming suites, quoting deltas, and proposing likely causes when visible from this flight's changes.
- The Developer provides structured debrief input - The Developer provides structured debrief input
#### Architect Interview #### Architect Interview
@@ -93,6 +94,12 @@ Synthesize Developer input, Architect input, human input, and document analysis
- What technical debt was introduced? - What technical debt was introduced?
- Does implementation align with project conventions? - Does implementation align with project conventions?
#### Test Metrics Capture
- Run the full test suite as part of the debrief and record metrics: per-suite wall-clock timing, pass/fail counts, skipped/ignored tests, and any flakes observed on this run.
- Read prior flight debriefs in this project for earlier metrics observations. Compare current numbers against whatever priors exist.
- Surface meaningful changes — significant slowdowns, new failures, growing skip lists, recurring flakes — as narrative observations in whichever existing section best fits (Technical, Key Learnings, or What Could Be Improved).
- If this is the first flight to capture metrics, the numbers seed comparisons for future flights.
#### Deviation Analysis #### Deviation Analysis
- What deviations occurred and why? - What deviations occurred and why?
- Were deviations captured in the flight log? - Were deviations captured in the flight log?

View File

@@ -37,6 +37,49 @@ Create a technical flight spec from a mission.
- What's been completed vs. in progress? - What's been completed vs. in progress?
- Are there dependencies on other flights? - Are there dependencies on other flights?
6. **Review recent flight debriefs for relevant observations**
- Read recent flight debriefs in the project (within and adjacent to this mission)
- Look for test metrics observations: known slow suites, recurring flakes, persistent failures, growing skip lists — anything narrated by prior debriefs
- Look for unresolved technical concerns or recommendations that touch this flight's likely scope
- Carry forward into the technical approach and risk picture: e.g. "this area has slow integration tests, account for that in time estimates"; "known flake in suite X, plan to fix or quarantine if touching that area"; "prior debrief flagged Y — verify whether still relevant"
### Phase 1b: Upstream Reconnaissance
**Applies when**: The flight sources work items from a prior artifact that cites specific code locations — a maintenance report, a flight or mission debrief that enumerates outstanding follow-ups, an issue tracker, or a security audit. Skip this phase for greenfield flights where no source artifact pre-enumerates findings.
Source artifacts go stale. Items cited weeks or even days ago may have been incidentally fixed by intervening flights, partially addressed, or the cited file/line may have moved. Without a recon pass, stale items get drafted into legs and only get caught at design review or implementation — wasted artifact churn and rework.
**Goal**: Before designing legs, walk every cited item against current code and classify it.
1. **Enumerate source items**
- Read the source artifact in full and identify every item it treats as outstanding, actionable work — regardless of how the artifact organizes them. Different projects use different headings, severity labels, and status conventions; identify items by intent, not by literal section name.
- Capture each item's cited file paths, line numbers, and the change it describes
2. **Verify each item against current code**
- Read the cited locations (or grep for the cited symbols if line numbers have drifted)
- Determine whether the described gap still exists
3. **Classify each item** into one of:
- **`confirmed-live`** — gap still exists, item is real work
- **`already-satisfied`** — code now reflects what the item asked for; recommend retiring
- **`partially-satisfied`** — some sub-points done, others not; scope down
- **`needs-human-recheck`** — cannot be verified mechanically (runtime state, external system, credentials, infrastructure that isn't in the repo)
- **`drifted`** — cited location moved or symbol renamed; needs re-locating before classification
4. **Produce a Reconnaissance Report**
- Append the report to the flight log under a clearly-titled section (something like `## Reconnaissance Report` if the project's flight-log conventions don't dictate otherwise)
- One row per source item: `{item-id} | {classification} | {evidence: file:line or "cannot verify from repo"} | {recommendation}`
- For `already-satisfied`: cite the specific code that satisfies the item, so the user can audit your call
5. **Default to flag-for-human, not auto-retire**
- Do NOT silently drop items classified as `already-satisfied` or `partially-satisfied`
- Present the recon report to the user before proceeding to Phase 2 and ask: "Confirm retirements / accept partial scope / override any classifications?"
- The user has authority to keep an item live even if you think it's satisfied (e.g., the satisfying code is incomplete in ways you can't see)
6. **Carry retired items into the flight artifact**
- In the section of the flight artifact the project uses to enumerate scope items contributing to mission criteria, retired items appear as completed `[x]` entries with the satisfying evidence inline — they are NOT silently dropped from the spec
- This preserves traceability: a future reader can see all source items were considered, and which ones were judged already-satisfied during reconnaissance
### Phase 2: Code Interrogation ### Phase 2: Code Interrogation
Explore the target project's codebase to inform the technical approach: Explore the target project's codebase to inform the technical approach:
@@ -158,9 +201,11 @@ Break flights into legs based on technical boundaries:
**For documentation**: Consider whether README, CLAUDE.md, or other docs need updates as part of this flight — especially for flights adding new CLI commands, API endpoints, or configuration options **For documentation**: Consider whether README, CLAUDE.md, or other docs need updates as part of this flight — especially for flights adding new CLI commands, API endpoints, or configuration options
**For carry-forward debt from prior debriefs**: when multiple small items touch the same files or directories, bundle them into a single shared-surface leg rather than scheduling separate flights or letting them decay. Items that don't share a surface stay separate — bundling unrelated work just to "pull forward" is how scope creep enters maintenance work.
**For schema changes**: Include explicit migration legs and verify against the live database, not just mocks **For schema changes**: Include explicit migration legs and verify against the live database, not just mocks
**For UAT and alignment**: During the crew interview, ask the user whether they'd like to include a UAT and alignment leg. Explain that this optional leg is a guided UAT session — the agent walks the user through a series of tests and verification steps, fixing issues along the way until the user is satisfied with the results. If the user opts in, include it in the breakdown, marked as optional. **For HAT and alignment**: During the crew interview, ask the user whether they'd like to include an interactive HAT (human acceptance test) and alignment leg. Explain that this optional leg is a guided HAT session — the agent walks the user through a series of tests and verification steps, fixing issues along the way until the user is satisfied with the results. If the user opts in, include it in the breakdown, marked as optional.
### Pre-Flight Rigor ### Pre-Flight Rigor

View File

@@ -1,6 +1,6 @@
# Flight Operations # Flight Operations
This directory contains reference materials for the [Flight Control](https://github.com/anthropics/flight-control) development methodology. This directory contains reference materials for the [Flight Control](https://github.com/msieurthenardier/mission-control) development methodology.
## Contents ## Contents

View File

@@ -33,7 +33,7 @@ Check for legacy directory layouts and offer to migrate them.
2. **Run detection checks** for each migration in order (001, 002, ...) 2. **Run detection checks** for each migration in order (001, 002, ...)
3. **If no migrations are needed**, proceed silently to the next step 3. **If no migrations are needed**, proceed silently to the next step
4. **If any migrations are needed**, present a summary to the user: 4. **If any migrations are needed**, present a summary to the user:
> "Detected legacy directory layout in {project}. The following migrations are available:" > "Detected legacy directory layout in {target-project}. The following migrations are available:"
> >
> - _Each applicable migration's user message_ > - _Each applicable migration's user message_
> >
@@ -61,13 +61,13 @@ The script outputs one of:
Based on the status: Based on the status:
**If `missing`**: **If `missing`**:
> "Flight operations directory not found. Create `{project}/.flightops/` with methodology references?" > "Flight operations directory not found. Create `{target-project}/.flightops/` with methodology references?"
**If `outdated`**: **If `outdated`**:
> "Flight operations references in {project} are outdated. Update?" > "Flight operations references in {target-project} are outdated. Update?"
**If `current`**: **If `current`**:
> "Flight operations references are up-to-date in {project}." > "Flight operations references are up-to-date in {target-project}."
If the user confirms, create/update the directory: If the user confirms, create/update the directory:
@@ -79,34 +79,13 @@ cp "${SKILL_DIR}/README.md" "{target-project}/.flightops/"
### 5. Configure Artifact System (New Projects Only) ### 5. Configure Artifact System (New Projects Only)
**Only if ARTIFACTS.md doesn't exist**, ask the user to select an artifact system: **Only if ARTIFACTS.md doesn't exist**, copy the template:
> "How should mission, flight, and leg artifacts be stored?"
Available templates:
- **files** — Markdown files in the repository (`templates/ARTIFACTS-files.md`)
- **jira** — Jira issues: Epics, Stories, Sub-tasks (`templates/ARTIFACTS-jira.md`)
#### 5a. Check for Setup Questions
After the user selects a template, read the template file and check if it contains a `## Setup Questions` section with a table of questions.
If setup questions exist:
1. Parse the questions from the table (first column contains the questions)
2. Ask the user each question interactively
3. Replace the placeholder answers in the table with the user's responses
#### 5b. Copy and Populate Template
Copy the selected template, with answers populated if setup questions were asked:
```bash ```bash
cp "${SKILL_DIR}/templates/ARTIFACTS-{selection}.md" \ cp "${SKILL_DIR}/templates/ARTIFACTS-files.md" \
"{target-project}/.flightops/ARTIFACTS.md" "{target-project}/.flightops/ARTIFACTS.md"
``` ```
If setup questions were answered, update the ARTIFACTS.md file to replace the placeholder answers with the user's responses.
**If ARTIFACTS.md already exists**, do not modify it — it's project-specific and may have been customized. **If ARTIFACTS.md already exists**, do not modify it — it's project-specific and may have been customized.
### 6. Configure Project Crew ### 6. Configure Project Crew
@@ -166,7 +145,7 @@ This project uses [Flight Control](https://github.com/msieurthenardier/mission-c
After creating or updating the directory, inform the user: After creating or updating the directory, inform the user:
> "If you have Claude Code running in {project}, restart it to pick up the new flight operations references." > "If you have Claude Code running in {target-project}, restart it to pick up the new flight operations references."
This ensures Claude Code loads the new files into its context when working in the target project. This ensures Claude Code loads the new files into its context when working in the target project.
@@ -175,7 +154,7 @@ This ensures Claude Code loads the new files into its context when working in th
This skill creates/updates the following at project root: This skill creates/updates the following at project root:
``` ```
{project}/ {target-project}/
├── CLAUDE.md # Updated with Flight Operations section ├── CLAUDE.md # Updated with Flight Operations section
└── .flightops/ # Hidden directory for Flight Control └── .flightops/ # Hidden directory for Flight Control
├── README.md # Explains the directory purpose ├── README.md # Explains the directory purpose

View File

@@ -6,7 +6,7 @@ human and project-side agents to capture both execution and design perspectives.
## Crew ## Crew
### Developer ### Developer
- **Context**: {project}/ - **Context**: {target-project}/
- **Model**: Sonnet - **Model**: Sonnet
- **Role**: Provides developer perspective on flight execution. Reviews what was - **Role**: Provides developer perspective on flight execution. Reviews what was
built, identifies technical debt introduced, evaluates implementation quality, built, identifies technical debt introduced, evaluates implementation quality,
@@ -14,7 +14,7 @@ human and project-side agents to capture both execution and design perspectives.
- **Actions**: debrief-interview - **Actions**: debrief-interview
### Architect ### Architect
- **Context**: {project}/ - **Context**: {target-project}/
- **Model**: Sonnet - **Model**: Sonnet
- **Role**: Closes the design feedback loop. Evaluates whether the design decisions - **Role**: Closes the design feedback loop. Evaluates whether the design decisions
made during flight planning held up in practice. Reviews architectural impact of made during flight planning held up in practice. Reviews architectural impact of

View File

@@ -6,7 +6,7 @@ technical spec and uses project-side agents to validate against the real codebas
## Crew ## Crew
### Architect ### Architect
- **Context**: {project}/ - **Context**: {target-project}/
- **Model**: Sonnet - **Model**: Sonnet
- **Role**: Reviews flight specs for technical soundness. Validates design - **Role**: Reviews flight specs for technical soundness. Validates design
decisions, prerequisites, technical approach, and leg breakdown against decisions, prerequisites, technical approach, and leg breakdown against
@@ -56,6 +56,20 @@ Evaluate:
5. Codebase state — does the spec account for current working tree, existing tooling, 5. Codebase state — does the spec account for current working tree, existing tooling,
and conventions that might affect implementation? and conventions that might affect implementation?
6. Architecture — does the approach maintain or improve system structure? 6. Architecture — does the approach maintain or improve system structure?
7. State-machine reachability — for every state, status, or lifecycle value the flight
introduces or relies on (e.g. "agent_deleted", "draft", "queued"), audit which
infrastructure layers could foreclose it: DB constraints (FK ON DELETE behaviors,
NOT NULL, CHECK), application caches and their invalidation rules, API/protocol
versions, fallback handlers that mask the state, and existing tests that pin
contradictory behavior. A state that the schema or a cache can silently prevent
is a design hole, not an implementation detail.
8. Cache freshness contracts — for every cache (in-memory dict, query result cache,
derived state, frontend session storage) the flight reads from or populates,
the design must declare source of truth, rebuild trigger (per-call / TTL /
invalidation event / accepted permanent staleness), maximum staleness, and which
user actions should invalidate it. Vague answers ("eventually", "on next cycle")
without a concrete trigger are a flag. Conflating "cached object works" with
"cached object reflects current source" is a common category error worth catching.
Provide structured output: Provide structured output:

View File

@@ -7,21 +7,21 @@ The Flight Director (Mission Control) orchestrates this phase using the
## Crew ## Crew
### Developer ### Developer
- **Context**: {working-directory}/ - **Context**: {target-project}/
- **Model**: Sonnet - **Model**: Sonnet
- **Role**: Implements code changes. Also performs design reviews against real - **Role**: Implements code changes. Also performs design reviews against real
codebase to validate leg specs before implementation. codebase to validate leg specs before implementation.
- **Actions**: implement, fix-review-issues, commit, review-leg-design - **Actions**: implement, fix-review-issues, commit, review-leg-design
### Reviewer ### Reviewer
- **Context**: {working-directory}/ - **Context**: {target-project}/
- **Model**: Sonnet (NEVER Opus) - **Model**: Sonnet (NEVER Opus)
- **Role**: Reviews code changes for quality, correctness, and criteria compliance. - **Role**: Reviews code changes for quality, correctness, and criteria compliance.
Has NO knowledge of Developer's reasoning — only sees resulting changes. Has NO knowledge of Developer's reasoning — only sees resulting changes.
- **Actions**: review - **Actions**: review
### Accessibility Reviewer (optional) ### Accessibility Reviewer (optional)
- **Context**: {working-directory}/ - **Context**: {target-project}/
- **Model**: Sonnet - **Model**: Sonnet
- **Enabled**: false - **Enabled**: false
- **Role**: Reviews UI changes for accessibility compliance. Evaluates against - **Role**: Reviews UI changes for accessibility compliance. Evaluates against
@@ -72,7 +72,6 @@ The Flight Director substitutes these variables in prompts at runtime:
| `{flight-number}` | Current flight number | All prompts | | `{flight-number}` | Current flight number | All prompts |
| `{leg-number}` | Current leg number | Leg-scoped prompts | | `{leg-number}` | Current leg number | Leg-scoped prompts |
| `{leg-artifact-path}` | Path to the leg artifact file | review-leg-design | | `{leg-artifact-path}` | Path to the leg artifact file | review-leg-design |
| `{working-directory}` | Resolved working directory for the agent (project root for branch strategy, worktree path for worktree strategy) | All prompts |
| `{reviewer-issues}` | Full text of reviewer feedback (dynamic) | fix-review-issues | | `{reviewer-issues}` | Full text of reviewer feedback (dynamic) | fix-review-issues |
## Prompts ## Prompts

View File

@@ -6,7 +6,7 @@ both the human and a project-side Architect to capture strategic technical persp
## Crew ## Crew
### Architect ### Architect
- **Context**: {project}/ - **Context**: {target-project}/
- **Model**: Sonnet - **Model**: Sonnet
- **Role**: Provides architectural perspective on mission outcomes. Evaluates - **Role**: Provides architectural perspective on mission outcomes. Evaluates
whether the system evolved well across flights, identifies structural issues, whether the system evolved well across flights, identifies structural issues,

View File

@@ -6,7 +6,7 @@ and uses project-side agents to validate technical viability.
## Crew ## Crew
### Architect ### Architect
- **Context**: {project}/ - **Context**: {target-project}/
- **Model**: Sonnet - **Model**: Sonnet
- **Role**: Validates technical viability of proposed outcomes. Ensures business - **Role**: Validates technical viability of proposed outcomes. Ensures business
goals align with what's actually possible given the codebase, stack, and goals align with what's actually possible given the codebase, stack, and

View File

@@ -7,7 +7,7 @@ for severity assessment and roundtable moderation.
## Crew ## Crew
### Inspector ### Inspector
- **Context**: {project}/ - **Context**: {target-project}/
- **Model**: Sonnet - **Model**: Sonnet
- **Role**: Performs broad read-only codebase inspection across all applicable - **Role**: Performs broad read-only codebase inspection across all applicable
categories. Runs test suites, linters, type checkers, audit commands, and categories. Runs test suites, linters, type checkers, audit commands, and
@@ -15,7 +15,7 @@ for severity assessment and roundtable moderation.
- **Actions**: inspect-codebase - **Actions**: inspect-codebase
### Security Reviewer ### Security Reviewer
- **Context**: {project}/ - **Context**: {target-project}/
- **Model**: Sonnet - **Model**: Sonnet
- **Role**: Performs focused manual security review of authentication flows, - **Role**: Performs focused manual security review of authentication flows,
injection surfaces, secrets handling, CORS/CSP configuration, and data injection surfaces, secrets handling, CORS/CSP configuration, and data
@@ -24,7 +24,7 @@ for severity assessment and roundtable moderation.
- **Actions**: review-security - **Actions**: review-security
### CI/CD Reviewer (optional) ### CI/CD Reviewer (optional)
- **Context**: {project}/ - **Context**: {target-project}/
- **Model**: Sonnet - **Model**: Sonnet
- **Enabled**: false (enable when project has CI/CD pipelines) - **Enabled**: false (enable when project has CI/CD pipelines)
- **Role**: Reviews CI/CD pipeline configuration, build security, deployment - **Role**: Reviews CI/CD pipeline configuration, build security, deployment
@@ -33,7 +33,7 @@ for severity assessment and roundtable moderation.
- **Actions**: review-cicd - **Actions**: review-cicd
### Accessibility Reviewer (optional) ### Accessibility Reviewer (optional)
- **Context**: {project}/ - **Context**: {target-project}/
- **Model**: Sonnet - **Model**: Sonnet
- **Enabled**: false (enable when project has user-facing UI) - **Enabled**: false (enable when project has user-facing UI)
- **Role**: Reviews codebase for accessibility compliance against WCAG 2.1 AA - **Role**: Reviews codebase for accessibility compliance against WCAG 2.1 AA
@@ -42,7 +42,7 @@ for severity assessment and roundtable moderation.
- **Actions**: review-accessibility - **Actions**: review-accessibility
### Architect ### Architect
- **Context**: {project}/ - **Context**: {target-project}/
- **Model**: Opus - **Model**: Opus
- **Role**: Reviews all reviewer findings alongside debrief context. Assigns - **Role**: Reviews all reviewer findings alongside debrief context. Assigns
severity per finding, challenges questionable assessments, moderates severity per finding, challenges questionable assessments, moderates
@@ -463,6 +463,7 @@ For each finding, assign one of:
- Does this finding represent a real risk, or is it noise? - Does this finding represent a real risk, or is it noise?
- Is the severity proportional to the actual impact? - Is the severity proportional to the actual impact?
- Would this compound if left for another cycle? - Would this compound if left for another cycle?
- Is the infrastructure or framing this finding pertains to still serving its original purpose, or has it drifted into "maybe-someday" territory?
- Is this a new discovery or previously acknowledged debt? - Is this a new discovery or previously acknowledged debt?
- Do multiple reviewers corroborate the same issue? - Do multiple reviewers corroborate the same issue?
- Are any reviewer assessments questionable — too alarmist or too dismissive? - Are any reviewer assessments questionable — too alarmist or too dismissive?

View File

@@ -5,7 +5,7 @@ This project stores Flight Control artifacts as markdown files in the repository
## Directory Structure ## Directory Structure
``` ```
{project}/ {target-project}/
├── missions/ ├── missions/
│ └── {NN}-{mission-slug}/ │ └── {NN}-{mission-slug}/
│ ├── mission.md │ ├── mission.md
@@ -159,7 +159,7 @@ How the objective will be achieved.
- [ ] `{leg-slug}` - {Brief description} - [ ] `{leg-slug}` - {Brief description}
- [ ] `{leg-slug}` - {Brief description} - [ ] `{leg-slug}` - {Brief description}
- [ ] `uat-and-alignment` *(optional)* - Guided UAT session with iterative fixes - [ ] `hat-and-alignment` *(optional)* - Guided HAT (human acceptance test) session with iterative fixes
--- ---
@@ -560,16 +560,3 @@ States are tracked in the frontmatter or status field of each artifact:
- **Flight briefings**: Created before execution, not modified after - **Flight briefings**: Created before execution, not modified after
- **Debriefs**: Created after completion, may be updated with follow-up notes - **Debriefs**: Created after completion, may be updated with follow-up notes
- **Mission as briefing**: The mission.md document serves as both definition and briefing (no separate mission-briefing.md) - **Mission as briefing**: The mission.md document serves as both definition and briefing (no separate mission-briefing.md)
## Git Workflow
| Property | Value |
|----------|-------|
| Strategy | `branch` |
**Options:**
- **`branch`** (default) — Single-checkout workflow. The orchestrator creates a feature branch and all agents work in the project root. One flight at a time per working copy.
- **`worktree`** — Worktree isolation. The orchestrator creates a git worktree under `.worktrees/` for each flight. Agents work in the worktree path. Parallel flights are possible on a single repo clone.
When using the `worktree` strategy, add `.worktrees/` to `.gitignore`.

View File

@@ -1,408 +0,0 @@
# Artifact System: Jira
This project stores Flight Control artifacts as Jira issues.
## Issue Type Mapping
| Flight Control | Jira Issue Type | Hierarchy |
|----------------|-----------------|-----------|
| Mission | Epic | Parent |
| Flight | Story | Child of Epic |
| Leg | Sub-task | Child of Story |
## Setup Questions
Answer these questions when configuring Jira artifacts for your project:
| Question | Answer |
|----------|--------|
| What is the Jira project key? | `PROJECT` |
| JQL query for discovering flight documentation? | (e.g., `project = PROJECT AND labels = flight-control`) |
## Configuration
| Property | Value |
|----------|-------|
| Project Key | `PROJECT` |
| Board | (specify board name or ID) |
| Labels | `flight-control` |
---
## Custom Fields
<!-- Add your project's custom Jira fields here -->
| Custom Field | Jira Field ID | Required | Used For | Notes |
|--------------|---------------|----------|----------|-------|
| (example) Team | `customfield_10001` | Yes | All issues | Select from predefined teams |
| (example) Sprint | `customfield_10002` | No | Stories, Sub-tasks | Assign to sprint |
## Project Rules
<!-- Document project-specific Jira rules and conventions here -->
### Required Fields by Issue Type
**Epic (Mission):**
- (list required fields for your project)
**Story (Flight):**
- (list required fields for your project)
**Sub-task (Leg):**
- (list required fields for your project)
### Workflow Rules
- (document any workflow restrictions or automation rules)
- (e.g., "Stories cannot move to In Progress without Epic Link")
### Naming Conventions
- (document any naming patterns required by your project)
- (e.g., "Epic summaries must start with [MISSION]")
---
## Core Artifacts
### Mission → Epic
| Field | Mapping |
|-------|---------|
| Summary | Mission title |
| Description | See format below |
| Labels | `flight-control`, `mission` |
**Description Format:**
```
## Outcome
{What success looks like in human terms}
## Context
{Why this mission matters now}
## Success Criteria
- [ ] {Criterion 1}
- [ ] {Criterion 2}
## Stakeholders
{Who cares about this outcome}
## Constraints
{Non-negotiable boundaries}
## Environment Requirements
{Development and runtime requirements}
## Open Questions
{Unknowns needing resolution}
## Known Issues
Emergent blockers and issues discovered during execution. Add items here as flights surface problems that affect the broader mission — things not anticipated during planning but visible at the mission level.
- [ ] {Issue description} — discovered in Flight {N}, affects {scope}
## Flights
> **Note:** These are tentative suggestions, not commitments. Flights are planned and created one at a time as work progresses. This list will evolve based on discoveries during implementation.
- [ ] Flight 1: {description}
```
---
### Flight → Story
| Field | Mapping |
|-------|---------|
| Summary | Flight title |
| Description | See format below |
| Epic Link | Parent mission epic |
| Labels | `flight-control`, `flight` |
**Description Format:**
```
## Objective
{What this flight accomplishes}
## Contributing to Criteria
- {Relevant success criterion 1}
- {Relevant success criterion 2}
## Design Decisions
{Key technical decisions and rationale}
## Prerequisites
- [ ] {What must be true before execution}
## Technical Approach
{How the objective will be achieved}
## Legs
> **Note:** These are tentative suggestions, not commitments. Legs are planned and created one at a time as the flight progresses. This list will evolve based on discoveries during implementation.
- [ ] {leg-slug} - {description}
## Validation Approach
{How will this flight be validated? Manual testing, automated tests, or both?}
## Verification
{How to confirm success}
```
---
### Leg → Sub-task
| Field | Mapping |
|-------|---------|
| Summary | Leg title |
| Description | See format below |
| Parent | Flight story |
| Labels | `flight-control`, `leg` |
**Description Format:**
```
## Objective
{Single sentence: what this leg accomplishes}
## Context
{Design decisions and learnings from prior legs}
## Inputs
{What must exist before this leg runs}
## Outputs
{What exists after completion}
## Acceptance Criteria
- [ ] {Criterion 1}
- [ ] {Criterion 2}
## Verification Steps
How to confirm each criterion is met:
- {Command or manual check for criterion 1}
- {Command or manual check for criterion 2}
## Implementation Guidance
{Step-by-step guidance}
## Files Affected
{List of files to modify}
```
---
## Supporting Artifacts
### Flight Log → Story Comments
| Property | Value |
|----------|-------|
| Location | Comments on the Flight (Story) |
| Format | Timestamped comments with prefix |
| Update pattern | Append new comments during execution |
**Comment Format:**
```
[Flight Log] {YYYY-MM-DD HH:MM}
## {Entry Type}: {Title}
{Content based on entry type - see below}
```
**Entry Types:**
- `Leg Progress` - Status updates for leg completion
- `Decision` - Runtime decisions not in original plan
- `Deviation` - Departures from planned approach
- `Anomaly` - Unexpected issues encountered
- `Session Notes` - General progress notes
---
### Flight Briefing → Story Comment
| Property | Value |
|----------|-------|
| Location | Comment on the Flight (Story) |
| Created | Before flight execution begins |
| Label | `[Flight Briefing]` |
**Comment Format:**
```
[Flight Briefing] {YYYY-MM-DD}
## Mission Context
{How this flight contributes to mission}
## Objective
{What this flight will accomplish}
## Key Decisions
{Critical decisions crew should know}
## Risks
| Risk | Mitigation |
|------|------------|
| {risk} | {mitigation} |
## Legs Overview
1. {leg} - {description}
2. {leg} - {description}
## Success Criteria
{How we'll know the flight succeeded}
```
---
### Flight Debrief → Story Comment
| Property | Value |
|----------|-------|
| Location | Comment on the Flight (Story) |
| Created | After flight lands or diverts |
| Label | `[Flight Debrief]` |
**Comment Format:**
```
[Flight Debrief] {YYYY-MM-DD}
**Status**: {landed | aborted}
**Duration**: {start} - {end}
**Legs Completed**: {X of Y}
## Outcome Assessment
{What the flight accomplished}
## What Went Well
{Effective patterns}
## What Could Be Improved
{Recommendations}
## Deviations
| Deviation | Reason | Standardize? |
|-----------|--------|--------------|
| {what} | {why} | {yes/no} |
## Key Learnings
{Insights for future flights}
## Recommendations
1. {Most impactful recommendation}
2. {Second recommendation}
3. {Third recommendation}
## Action Items
- [ ] {action}
```
---
### Mission Debrief → Epic Comment
| Property | Value |
|----------|-------|
| Location | Comment on the Mission (Epic) |
| Created | After mission completes or aborts |
| Label | `[Mission Debrief]` |
**Comment Format:**
```
[Mission Debrief] {YYYY-MM-DD}
**Status**: {completed | aborted}
**Duration**: {start} - {end}
**Flights Completed**: {X of Y}
## Success Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| {criterion} | {met/not met} | {notes} |
## Flight Summary
| Flight | Status | Outcome |
|--------|--------|---------|
| {flight} | {status} | {outcome} |
## What Went Well
{Successes}
## What Could Be Improved
{Improvements}
## Lessons Learned
{Insights}
## Action Items
- [ ] {action}
```
---
## State Mapping
### Mission (Epic)
| Flight Control | Jira Status |
|----------------|-------------|
| planning | To Do |
| active | In Progress |
| completed | Done |
| aborted | Cancelled |
### Flight (Story)
| Flight Control | Jira Status |
|----------------|-------------|
| planning | To Do |
| ready | Ready |
| in-flight | In Progress |
| landed | In Review |
| completed | Done |
| aborted | Cancelled |
### Leg (Sub-task)
| Flight Control | Jira Status |
|----------------|-------------|
| planning | To Do |
| ready | Ready |
| in-flight | In Progress |
| landed | In Review |
| completed | Done |
| aborted | Cancelled |
---
## Conventions
- **Naming**: Use clear, action-oriented summaries
- **Linking**: Always link Stories to Epic, Sub-tasks to Story
- **Labels**: Apply `flight-control` label to all artifacts
- **Immutability**: Never modify Sub-tasks once In Progress; create new ones
- **Comments**: Use prefixes (`[Flight Log]`, `[Flight Briefing]`, etc.) for filtering
## Git Workflow
| Property | Value |
|----------|-------|
| Strategy | `branch` |
**Options:**
- **`branch`** (default) — Single-checkout workflow. The orchestrator creates a feature branch and all agents work in the project root. One flight at a time per working copy.
- **`worktree`** — Worktree isolation. The orchestrator creates a git worktree under `.worktrees/` for each flight. Agents work in the worktree path. Parallel flights are possible on a single repo clone.
When using the `worktree` strategy, add `.worktrees/` to `.gitignore`.

View File

@@ -79,12 +79,31 @@ Deep dive into the specific implementation:
- What error handling is required? - What error handling is required?
- If this leg modifies database schemas: does it include migration creation AND execution? Both must happen in the same leg — a schema defined but never migrated is a gap. - If this leg modifies database schemas: does it include migration creation AND execution? Both must happen in the same leg — a schema defined but never migrated is a gap.
5. **Identify dependent code** (for interface changes) 5. **State-machine reachability audit**
For every state, status, or lifecycle value this leg introduces or relies on, verify nothing in a lower layer makes the state unreachable.
- Enumerate the infrastructure layers that could foreclose the state: DB constraints (FK ON DELETE behaviors, NOT NULL, CHECK constraints, triggers), application caches and their invalidation rules, API/protocol version compatibility, fallback handlers that silently mask the state, and indexes that imply assumptions.
- Audit existing tests for assertions that contradict the new design. A test that pins behavior the new state machine breaks must be inverted, renamed, or deleted as part of this leg — not after. The rename pattern (e.g., `test_X_does_Y``test_X_does_not_do_Y` with the assertion inverted) is preferred over delete-and-readd because it documents the intent shift in git blame.
- When a design requires a row to persist past a referenced entity's deletion, explicitly inspect the schema's FK behaviors and any tests that pin them. A `ON DELETE CASCADE` on a referencing column will silently delete the row your design needs to keep.
6. **Cache freshness contract**
For every cache (in-memory dict, query result cache, computed derived state, frontend session storage) this leg reads from or populates, declare an explicit freshness contract.
- **Source of truth**: which underlying data does this cache reflect?
- **Rebuild trigger**: pick exactly one — per-call rebuild, TTL with value, invalidation event, or accepted permanent staleness until process restart.
- **Maximum staleness acceptable to the user**: be specific. "Until process restart" is rarely acceptable for user-facing state where users expect their edits to apply.
- **User-action invalidation map**: list every user action that mutates the source-of-truth, and confirm each one invalidates or refreshes the cache. If a user can edit X in one UI surface but the cached X never sees the edit elsewhere, that mismatch will surface as a bug — name it now.
Conflating "the cached object works fine" with "the cached object reflects current config" is a common error. State health and state freshness are different contracts.
7. **Identify dependent code** (for interface changes)
- Does this leg modify shared interfaces? - Does this leg modify shared interfaces?
- What files consume these interfaces? - What files consume these interfaces? Run `grep -rn '<changed_symbol>' tests/ src/`; if any test or non-leg-scope source imports or calls it, signature changes break any "prior tests pass UNMODIFIED" acceptance criterion.
- Should updating consumers be part of this leg? - Should updating consumers be part of this leg?
6. **Identify platform considerations** 8. **Identify platform considerations**
- Does this leg touch OS-specific features? - Does this leg touch OS-specific features?
- What platform differences might affect implementation? - What platform differences might affect implementation?
@@ -92,6 +111,34 @@ Deep dive into the specific implementation:
Create the leg artifact using the format defined in `.flightops/ARTIFACTS.md`. Create the leg artifact using the format defined in `.flightops/ARTIFACTS.md`.
### Phase 3b: Citation Verification
Before marking the leg `ready`, mechanically validate every code-location citation in the draft artifact against current code. Citations drift between when a flight is designed and when its legs are designed (intervening legs commit changes; source artifacts age). Catching drift here prevents the implementing agent from chasing a stale `file:line` into the wrong code.
1. **Extract citations**
- Scan the leg artifact for code-location references matching `path/to/file.ext:line` or `path/to/file.ext:line-line` patterns
- Also collect symbol-form citations: `path/to/file.ext:symbol_name`
- Skip references to non-source artifacts (`mission.md:42`, `flight.md:100`) — those are out of scope
2. **Verify each citation**
- Read the cited location in current code
- Compare against the snippet, symbol, or surrounding description provided in the leg
- Classify each:
- **`OK`** — content at the cited location matches the description
- **`drifted`** — content moved; the description is still accurate but the line number is wrong
- **`gone`** — described content no longer exists in the file (or the file itself is gone)
- **`unverifiable`** — citation has no snippet/symbol and the description is too vague to confirm
3. **Repair drift inline**
- For `drifted`: locate the new line number via grep on the snippet/symbol and update the citation in the leg artifact
- For `gone`: do not silently retire — flag for human review (the gap may have been independently fixed, OR the leg may now be obsolete)
- For `unverifiable`: rewrite the citation using one of the durable forms (see "Citing Code Locations" guideline)
4. **Append a Citation Audit summary**
- At the bottom of the leg artifact, append a clearly-titled section (something like `## Citation Audit` if the project's leg conventions don't dictate otherwise) summarizing the audit
- If all citations verified clean: one sentence — `N citations verified against current code at leg design time.`
- If any drift was repaired or flagged: list each one with classification and resolution
## Guidelines ## Guidelines
### Writing Effective Objectives ### Writing Effective Objectives
@@ -132,6 +179,21 @@ For accessibility work, include specific checks:
- Screen reader commands to test - Screen reader commands to test
- Automated tool commands (Lighthouse, axe-core) - Automated tool commands (Lighthouse, axe-core)
### Citing Code Locations
When the leg artifact references specific code, prefer durable forms over bare line numbers. Line numbers drift; symbols and snippets do not.
| Form | Example | When to use |
|------|---------|-------------|
| `file:symbol` | `the_one/api.py:create_provider` | Most cases — symbol names survive line shifts |
| `file:line — "snippet"` | `the_one/api.py:805 — "raise ProviderConfigError"` | When a specific line matters; the snippet is a self-verifier |
| `file:CONSTANT_NAME` | `web/middleware.py:GATED_METHODS` | Module-level constants and assignments |
| `file:line` (bare) | `api.py:805` | **Avoid** — brittle, no way to verify drift |
The snippet form is especially valuable: it lets Phase 3b mechanically confirm the cited content didn't move, and it tells the implementing agent exactly what they're looking at without needing to chase the line number.
When in doubt, include both — `the_one/api.py:805 (in create_provider) — "raise ProviderConfigError if base_url is empty"` — symbol + line + snippet covers all three drift modes.
### Implementation Guidance ### Implementation Guidance
Be explicit, not implicit: Be explicit, not implicit:

View File

@@ -1,11 +1,11 @@
--- ---
name: routine-maintenance name: routine-maintenance
description: Codebase health assessment and maintenance recommendation. Use after a mission or ad-hoc to verify codebase is flight-ready or scaffold a maintenance mission. description: Post-mission codebase health assessment. Run after `/mission-debrief` to verify the codebase is flight-ready or scaffold a maintenance mission. Per-flight findings instead roll into the next flight or accumulate into an end-of-mission maintenance flight — not into this skill.
--- ---
# Routine Maintenance # Routine Maintenance
Perform an exhaustive, aviation-style codebase inspection. Can be triggered after a mission completes or run ad-hoc at any time. Produces a findings report and optionally scaffolds a maintenance mission for significant issues. Perform an exhaustive, aviation-style codebase inspection after a mission completes. Produces a findings report and optionally scaffolds a maintenance mission for significant issues.
## Prerequisites ## Prerequisites
@@ -31,12 +31,11 @@ Perform an exhaustive, aviation-style codebase inspection. Can be triggered afte
- Deferred findings are those documented in prior reports but not addressed by a maintenance mission - Deferred findings are those documented in prior reports but not addressed by a maintenance mission
- This ensures recurring issues are tracked across cycles rather than re-discovered as "new" - This ensures recurring issues are tracked across cycles rather than re-discovered as "new"
5. **Load mission and debrief documentation (if available)** 5. **Load mission and debrief documentation**
- If a recent mission exists, read it for outcome, success criteria, and known issues - Read the most recently completed mission for outcome, success criteria, and known issues
- If a mission debrief exists, read it for lessons learned and action items - Read its mission debrief for lessons learned and action items
- If flight debriefs exist, read them for per-flight technical debt and recommendations - Read its flight debriefs for per-flight technical debt and recommendations
- This provides known-debt context so the inspection can distinguish new issues from acknowledged ones - This provides known-debt context so the inspection can distinguish new issues from acknowledged ones
- If no mission context is available (ad-hoc run), proceed without known-debt context
6. **Identify project stack** 6. **Identify project stack**
- Read `README.md`, `CLAUDE.md`, and package files (`package.json`, `Cargo.toml`, `go.mod`, etc.) - Read `README.md`, `CLAUDE.md`, and package files (`package.json`, `Cargo.toml`, `go.mod`, etc.)
@@ -178,6 +177,7 @@ Each agent receives:
- Provide the "Inspect Codebase" prompt from the crew file's Prompts section - Provide the "Inspect Codebase" prompt from the crew file's Prompts section
- Include: applicable category list, project stack info, known debt from debriefs, user's areas of concern, and **scope assignment from the delegation plan** - Include: applicable category list, project stack info, known debt from debriefs, user's areas of concern, and **scope assignment from the delegation plan**
- For partitioned inspections: each Inspector agent receives its module/area scope and only the categories relevant to its assignment - For partitioned inspections: each Inspector agent receives its module/area scope and only the categories relevant to its assignment
- In addition to the crew prompt, the Flight Director directly instructs the Inspector to: scan recent flight debriefs for any test metrics observations (timing, failures, skipped tests, flakes) and look for trends across them — rising suite times, recurring flakes, growing skip lists, persistent failures. Surface concrete optimization recommendations as Category 2 findings (e.g. parallelization, mocking, fixture hoisting, slow-test extraction, runner config), each tied to evidence from the trend.
- The Inspector performs broad automated checks and returns structured findings per category - The Inspector performs broad automated checks and returns structured findings per category
#### Security Reviewer #### Security Reviewer

View File

@@ -2,6 +2,7 @@
projects.md projects.md
daily-briefings/ daily-briefings/
.claude/settings.local.json .claude/settings.local.json
.mcp.json
# OS files # OS files
.DS_Store .DS_Store

View File

@@ -49,7 +49,7 @@ Eleven skills automate the planning, execution, debrief, and oversight workflow:
| `/mission` | Create outcome-driven missions through research and interview | | `/mission` | Create outcome-driven missions through research and interview |
| `/flight` | Create technical flight specs from missions | | `/flight` | Create technical flight specs from missions |
| `/leg` | Generate implementation guidance for LLM execution | | `/leg` | Generate implementation guidance for LLM execution |
| `/agentic-workflow` | Drive multi-agent flight execution (design, implement, review, commit) | | `/agentic-workflow` | Drive multi-agent flight execution (design per leg, batch implement, single review and commit) |
| `/flight-debrief` | Post-flight analysis for continuous improvement | | `/flight-debrief` | Post-flight analysis for continuous improvement |
| `/mission-debrief` | Post-mission retrospective for outcomes assessment | | `/mission-debrief` | Post-mission retrospective for outcomes assessment |
| `/routine-maintenance` | Post-mission codebase health assessment and maintenance recommendation | | `/routine-maintenance` | Post-mission codebase health assessment and maintenance recommendation |
@@ -90,6 +90,27 @@ The registry provides:
- **Flights**: `planning``ready``in-flight``landed``completed` (or `aborted`) - **Flights**: `planning``ready``in-flight``landed``completed` (or `aborted`)
- **Legs**: `planning``ready``in-flight``landed``completed` (or `aborted`) - **Legs**: `planning``ready``in-flight``landed``completed` (or `aborted`)
## SkillProject Boundary
Mission Control skills run in projects whose owners can customize `.flightops/ARTIFACTS.md` and `.flightops/agent-crews/*.md` freely. Skills must not couple to project-owned shape:
- **Do not read project-owned artifacts by section heading.** When a skill needs to extract information from a prior debrief, maintenance report, or other project-owned artifact, frame the instruction by intent — what the agent is looking for — and let the agent locate it within whatever structure the project uses. Reading by literal heading name (e.g. `## Action Items`, `## Test Suite Timing`) breaks silently the moment a project owner renames or removes that section.
- **Do not write into project-owned artifacts at named anchors.** When a skill inserts content into a project artifact, describe the destination semantically ("in the section the project uses for X") rather than by literal heading. If the skill is appending a new section, suggest a heading without prescribing it as a contract.
- **Do not rely on crew prompt files to carry skill-required instructions.** The Flight Director must issue per-spawn instructions directly from the SKILL.md, even when the crew file also contains an overlapping prompt. Crew files are project-modifiable scaffolding; SKILL.md is the protocol.
See `docs/artifacts-md-ambiguities.md` for the full review of how the current ARTIFACTS.md template muddles this boundary.
## Project Information Stays in Project Artifacts
**Never store project-specific information in Claude Code memories** — not in mission-control's memory directory, not in any project's memory directory. Project-specific issues, bugs, technical debt, design gaps, known issues, and lessons learned belong exclusively in the project's own Flight Control artifacts:
- **Flight logs** — runtime decisions, deviations, anomalies
- **Flight debriefs** — post-flight analysis, recommendations, action items
- **Mission known issues** — cross-flight concerns discovered during execution
- **Design decision sections** — in flight and mission artifacts
Mission-control is a neutral methodology tool. Its memory (if used at all) is reserved for methodology preferences, user collaboration preferences, and cross-cutting tooling notes — never for project-specific content.
## Public Repository ## Public Repository
This is a public repository. Keep all committed content anonymized: This is a public repository. Keep all committed content anonymized:

View File

@@ -32,7 +32,7 @@ Aviation succeeds through layered planning and clear handoffs. Pilots follow fli
## Agentic Workflow ## Agentic Workflow
**LLM orchestrators**: Run `/agentic-workflow` to drive multi-agent flight execution with Claude Code. The skill orchestrates the full leg cycle — design, implement, review, commit using three separate Claude instances. **LLM orchestrators**: Run `/agentic-workflow` to drive multi-agent flight execution with Claude Code. The skill designs and implements each leg in turn, then runs a single code review and commit across the whole flight, using separate Claude instances for the Flight Director, Developer, and Reviewer roles.
## Getting Started ## Getting Started
@@ -49,13 +49,13 @@ Aviation succeeds through layered planning and clear handoffs. Pilots follow fli
3. **Initialize your project** — Run `/init-project` and select your project. This creates `.flightops/` in your target project with artifact configuration, methodology reference, and crew definitions. 3. **Initialize your project** — Run `/init-project` and select your project. This creates `.flightops/` in your target project with artifact configuration, methodology reference, and crew definitions.
4. **Review agent crew files** — Check the files in `{project}/.flightops/agent-crews/`. These define the crew composition (roles, models, prompts) for each phase. Customize them to your needs. 4. **Review agent crew files** — Check the files in `{target-project}/.flightops/agent-crews/`. These define the crew composition (roles, models, prompts) for each phase. Customize them to your needs.
5. **Create a mission** — Run `/mission`. This interviews you about desired outcomes and creates a mission artifact in your target project. 5. **Create a mission** — Run `/mission`. This interviews you about desired outcomes and creates a mission artifact in your target project.
6. **Design a flight** — Run `/flight` to break the mission into a technical specification with pre/in/post-flight checklists. 6. **Design a flight** — Run `/flight` to break the mission into a technical specification with pre/in/post-flight checklists.
7. **Execute** — Run `/agentic-workflow` to drive multi-agent implementation. This orchestrates design, implement, review, and commit cycles across legs. 7. **Execute** — Run `/agentic-workflow` to drive multi-agent implementation. This designs and implements each leg in turn, then reviews and commits the whole flight in one pass at the end.
8. **Debrief** — Run `/flight-debrief` and `/mission-debrief` after completion to capture lessons learned. 8. **Debrief** — Run `/flight-debrief` and `/mission-debrief` after completion to capture lessons learned.
@@ -106,13 +106,7 @@ Mission
└── Leg └── Leg
``` ```
How you store these artifacts depends on your project's needs. Flight Control supports multiple artifact systems: By default, artifacts are stored as version-controlled markdown files in your project's repository. Each project's `.flightops/ARTIFACTS.md` describes where and how artifacts live — skills read this file to determine locations and formats. You can adapt it to other backends (Jira, Linear, GitHub Issues, hybrid setups) by editing this file directly; only the markdown-files template ships out of the box.
- **Markdown files** — Version-controlled documentation in your repository
- **Issue trackers** — Jira, Linear, GitHub Issues with linked relationships
- **Hybrid** — Missions in markdown, flights/legs as tickets
Each project configures its artifact system during initialization. The methodology and Claude Code skills adapt to your choice.
## Claude Code Skills ## Claude Code Skills

View File

@@ -0,0 +1,91 @@
# ARTIFACTS.md Template — Ambiguities
A review of `.claude/skills/init-project/templates/ARTIFACTS-files.md` (the template copied into each project's `.flightops/ARTIFACTS.md`) against the implicit contract Mission Control skills rely on.
## Context
Mission Control skills make assumptions about what they can read, write, and depend on inside a project. ARTIFACTS.md is the document that's supposed to encode the contract between skills and project-side customization. In practice, the template muddles three different layers:
1. **Mission Control protocol invariants** — fixed across all projects; skills depend on these by name (state values, lifecycle rules, artifact taxonomy).
2. **Project conventions skills must read** — locations, file naming, status field encoding.
3. **Project presentation** — section structure, prose templates, headings; never read by skills.
The template doesn't distinguish between these layers, which causes two failure modes:
- Project owners cannot tell what they can safely customize and what is fixed.
- Skill authors are not steered away from depending on project-owned shape (the most recent example: a draft flight-debrief change that read prior debriefs by `## Test Suite Timing` heading — a heading that lives in the project-owned format and could be removed or renamed at any time).
This document lists the specific ambiguities that motivated the finding.
## Findings
### 1. State values are protocol but presented as project-customizable
The `## State Tracking` table lists `planning → ready → in-flight → landed → completed (or aborted)` as part of the project's local convention. Skills depend on these literal strings:
- `flight-debrief/SKILL.md`: "A flight must have status `landed` before debriefing"; "update the flight artifact's status from `landed` to `completed`"
- `routine-maintenance/SKILL.md`: scaffolds new flights with `Status: ready`
If a project owner renames `landed` to `arrived` in their ARTIFACTS.md, skills break silently. The template gives no signal that this list is fixed by the protocol.
### 2. State transition ownership is unspecified
The state diagrams show transitions but don't say who triggers each one:
- `planning → active` for missions: which actor performs this? No skill currently does it.
- `ready → in-flight` for legs: skill or human?
- `landed → completed`: `flight-debrief` does this conditionally on user confirmation.
A project owner reading the template cannot tell which transitions they own vs which the skills handle.
### 3. "Format" is overloaded
Skills routinely say "use the format defined in `.flightops/ARTIFACTS.md`." The template conflates three different things under that one word:
- **Locations** (`missions/{NN}-{slug}/mission.md`) — skills genuinely depend on these
- **Status field encoding** (e.g. `**Status**: planning`) — skills need to read this
- **Section structure** (`## Outcome`, `## Context`, etc.) — skills should NOT depend on this
There is no rule stated anywhere that skills read locations and status fields but never read by section heading. Without that rule, every new skill edit risks coupling itself to project-owned section structure.
### 4. Conventions section mixes protocol invariants with format choices
In `## Conventions` (lines 556562 of the template):
- "Never modify legs once `in-flight`" — protocol invariant; project owners cannot opt out
- "Flight logs are append-only during execution" — protocol invariant
- "Mission as briefing: mission.md serves as both" — project choice (a different project might have a separate briefing file)
A project owner cannot tell which lines are invariants vs which are policies they accepted at init time and could revise.
### 5. The taxonomy itself isn't declared fixed
The artifact types — Mission, Flight, Leg, Debrief, Log, Briefing, Maintenance Report — are protocol concepts. Skills speak their names. But the template is structured as if it's describing one project's choices ("This project stores Flight Control artifacts as markdown files"), which invites a reader to think they could rename "Flight" to "Sprint" or drop "Leg" entirely.
### 6. Status field placement is ambiguously specified
"States are tracked in the frontmatter or status field of each artifact." Skills need a deterministic read path. If a project mixes the two — frontmatter for missions but `**Status**:` lines for flights — every skill that reads status must handle both. The template offers a choice without forcing the project to commit to one.
### 7. Template placeholders read ambiguously
`{NN}`, `{slug}`, `{Title}` — are these substituted by skills at artifact-generation time, or by the project owner during init configuration? They are the former, but the file presents them as if they are placeholders awaiting human substitution. A naive project owner might "fill in" `{slug}` with a literal value.
### 8. The init-project / migrations contradiction
`init-project/SKILL.md` says ARTIFACTS.md is project-specific and "Never overwrite — it may contain customizations." But `init-project/migrations.md` describes Mission Control modifying ARTIFACTS.md to update state definitions. Mission Control reserves the right to edit a "project-owned" file when its own protocol changes. The template does not disclose this boundary.
### 9. No statement of the skill/project contract
The largest gap. Nowhere does the template say *what skills will and will not do with this file*. Adding a preamble that explicitly states the contract would resolve most of the issues above:
> Mission Control skills read this file for: artifact locations, file naming, and status field encoding. Skills do not read artifacts by section heading. The State Tracking values and lifecycle rules are fixed by Mission Control and should not be edited. Everything else is yours to customize.
## Proposed direction (not yet implemented)
A small restructure of the template that sorts every line into one of three buckets:
- **`[protocol: do not edit]`** — state values, lifecycle invariants, artifact taxonomy
- **`[project: skills read this]`** — locations, file naming, status field encoding
- **`[project: presentation only]`** — section structure, prose, headings
Plus a preamble that names the skill/project contract explicitly. This clarifies the boundary in the document where new project owners and new skill authors actually look.