Compare commits

...

28 Commits

Author SHA1 Message Date
38e65619e9 Fix tooltips clipped by overflow containers, improve Backend tooltip text
All checks were successful
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m44s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 12s
Rewrite Tooltip to use React portal (createPortal to document.body) so
tooltips render above all UI elements regardless of ancestor overflow:hidden.
Also increased max-width from 220px to 280px for longer descriptions.

Expanded Backend tooltip to explain each option (Anthropic, Bedrock,
Ollama, LiteLLM) with practical context for new users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:56:50 -07:00
d2c1c2108a Fix update checker to use full semver comparison and correct platform filtering
Some checks failed
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m20s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m21s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Has been cancelled
The version comparison was only comparing the patch number, ignoring major
and minor versions. This meant 0.1.75 (patch=75) appeared "newer" than
0.2.1 (patch=1), and updates within 0.2.x were missed entirely.

Also fixed platform filtering to handle -mac suffix (previously only
filtered -win, so Linux users would see macOS releases too).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:45:43 -07:00
cc163e6650 Add help dialog and tooltip indicators throughout the UI
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m29s
Build App / build-linux (push) Successful in 5m43s
Build App / create-tag (push) Successful in 7s
Build App / sync-to-github (push) Successful in 13s
- Add circled ? help button in TopBar that opens a dialog with HOW-TO-USE.md content
- Create reusable Tooltip component with viewport-aware positioning
- Add 32 tooltip indicators across project config and settings panels
- Covers backend selection, Bedrock/Ollama/LiteLLM fields, Docker, AWS, MCP, and more

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:35:04 -07:00
38082059a5 Rename AuthMode to Backend, fix LiteLLM variant typo, add image update alerts, clean up Settings
All checks were successful
Build App / compute-version (push) Successful in 6s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m14s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
- Fix serde deserialization error: TypeScript sent "lit_llm" but Rust expected "lite_llm"
- Rename AuthMode enum to Backend across Rust and TypeScript (with serde alias for backward compat)
- Add container image update checking via registry digest comparison
- Improve Settings page: fix image address display spacing, remove per-project auth section
- Update UI labels from "Auth" to "Backend" throughout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:26:58 -07:00
beae0942a1 Add dynamic versioning with VERSION file and tag-based patch counting
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m19s
Build App / build-windows (push) Successful in 3m22s
Build App / build-linux (push) Successful in 6m14s
Build App / create-tag (push) Successful in 8s
Build App / sync-to-github (push) Successful in 10s
- Create VERSION file (currently `0.2`) as the single source of truth for major.minor
- Add compute-version job that reads VERSION and counts commits since the last
  matching v{major.minor}.N tag to derive the patch number
- Patch resets automatically when VERSION is bumped (no matching tags exist yet)
- Add create-tag job that tags the repo after all platform builds succeed,
  so subsequent builds count from the new tag
- All platform jobs now consume the shared version instead of computing their own
- VERSION file changes trigger the build workflow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:11:04 -07:00
6b49981b3a Update build workflow version from 0.1.x to 0.2.x
All checks were successful
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 3m19s
Build App / build-linux (push) Successful in 6m37s
Build App / sync-to-github (push) Successful in 13s
The computed build version was hardcoded as 0.1.${COMMIT_COUNT} across all
three platform jobs (Linux, macOS, Windows), producing 0.1.x releases even
though the source files were bumped to 0.2.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:03:53 -07:00
b46b392a9a Update package-lock.json version to 0.2.0
All checks were successful
Build App / build-windows (push) Successful in 3m21s
Build App / build-linux (push) Successful in 5m2s
Build App / build-macos (push) Successful in 2m32s
Build App / sync-to-github (push) Successful in 13s
The lock file was still at 0.1.0 after the v0.2.0 version bump.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:23:08 -07:00
4889dd974f Change auth mode and Bedrock method selectors from button lists to dropdowns
Some checks failed
Build App / build-macos (push) Failing after 1s
Build App / build-windows (push) Successful in 4m4s
Build App / sync-to-github (push) Has been cancelled
Build App / build-linux (push) Has been cancelled
The horizontal button lists overflowed the viewable area of the card. Replace
with <select> dropdowns for both the main auth mode and the Bedrock sub-method.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:18:47 -07:00
b6fd8a557e Clean up compiler warnings and document Ollama/LiteLLM backends
All checks were successful
Build App / build-macos (push) Successful in 2m25s
Build App / build-windows (push) Successful in 3m22s
Build App / build-linux (push) Successful in 4m36s
Build App / sync-to-github (push) Successful in 10s
Remove unused `any_docker_mcp()` function, add `#[allow(unused_imports)]`
and `#[allow(dead_code)]` annotations to suppress false-positive warnings.
Update README.md and HOW-TO-USE.md with Ollama and LiteLLM auth backend
documentation including best-effort compatibility notices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:23:50 -07:00
93deab68a7 Add Ollama and LiteLLM backend support (v0.2.0)
All checks were successful
Build App / build-macos (push) Successful in 2m25s
Build App / build-windows (push) Successful in 3m27s
Build App / build-linux (push) Successful in 4m31s
Build App / sync-to-github (push) Successful in 9s
Add two new auth modes for projects alongside Anthropic and Bedrock:
- Ollama: connect to local or remote Ollama servers via ANTHROPIC_BASE_URL
- LiteLLM: connect through a LiteLLM proxy gateway to 100+ model providers

Both modes inject ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN env vars into
the container, with optional model override via ANTHROPIC_MODEL. LiteLLM
API keys are stored securely in the OS keychain. Config changes trigger
automatic container recreation via fingerprinting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:05:52 -07:00
2dce2993cc Fix AWS SSO for Bedrock profile auth in containers
All checks were successful
Build App / build-macos (push) Successful in 2m29s
Build App / build-windows (push) Successful in 3m56s
Build App / build-linux (push) Successful in 4m42s
Build Container / build-container (push) Successful in 54s
Build App / sync-to-github (push) Successful in 10s
SSO login was broken in containers due to three issues: the sso_session
indirection format not being resolved by Claude Code's AWS SDK, SSO
detection only checking sso_start_url (missing sso_session), and the
OAuth callback port not being accessible from inside the container.

This fix runs SSO login on the host OS (where the browser and ports work
natively) by having the container emit a marker that the Tauri app
detects in terminal output, triggering host-side `aws sso login`. The
entrypoint also inlines sso_session properties into profile sections and
injects awsAuthRefresh into Claude Code config for mid-session refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:24:16 -07:00
e482452ffd Expand MCP documentation with mode explanations and concrete examples
- Rewrite HOW-TO-USE.md MCP section with a mode matrix (stdio/http x
  manual/docker), four worked examples (filesystem, GitHub, custom HTTP,
  database), and detailed explanations of networking, auto-pull, and
  config injection
- Update README.md MCP architecture section with a mode table and
  key behaviors including auto-pull and Docker DNS details

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:19:03 -07:00
8c710fc7bf Auto-pull MCP Docker images and add mode hints to MCP UI
All checks were successful
Build App / build-macos (push) Successful in 2m25s
Build App / build-windows (push) Successful in 3m32s
Build App / build-linux (push) Successful in 5m34s
Build App / sync-to-github (push) Successful in 11s
- Automatically pull missing Docker images for MCP servers before
  starting containers, with progress streamed to the container
  progress modal
- Add contextual mode descriptions to MCP server cards explaining
  where commands run (project container vs separate MCP container)
- Clarify that HTTP+Docker URLs are auto-generated using the
  container hostname on the project network, not localhost

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:51:47 -07:00
b7585420ef Reconcile project statuses against Docker on startup, update docs and CI
All checks were successful
Build App / build-macos (push) Successful in 2m40s
Build App / build-windows (push) Successful in 4m12s
Build App / build-linux (push) Successful in 5m4s
Build Container / build-container (push) Successful in 2m41s
Build App / sync-to-github (push) Successful in 10s
- Add reconcile_project_statuses command that checks actual Docker container
  state on startup, preserving Running status for containers that are genuinely
  still running and resetting stale statuses to Stopped
- Add is_container_running helper using Docker inspect API
- Frontend calls reconciliation after Docker is confirmed available
- Update TECHNICAL.md project structure, auth modes, and file listings to
  match current codebase
- Update README.md and HOW-TO-USE.md with MCP servers, Mission Control,
  file manager, bash shells, clipboard/audio shims, and progress modal docs
- Add workflow file self-triggers to CI path filters for build-app.yml
  and build.yml
- Install Mission Control skills to ~/.claude/skills/ in entrypoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 08:29:06 -07:00
bf8ef3dba1 Fix file manager listing current folder as entry within itself
All checks were successful
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 3m22s
Build App / build-linux (push) Successful in 4m39s
Build App / sync-to-github (push) Successful in 10s
The find command included the starting directory in results (e.g., listing
"workspace" inside /workspace). Replace `-not -name "."` with `-mindepth 1`
which correctly excludes the starting path from output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 08:38:48 -08:00
418afe00ed Move project remove confirmation from inline buttons to modal popup
All checks were successful
Build App / build-macos (push) Successful in 2m29s
Build App / build-windows (push) Successful in 3m56s
Build App / build-linux (push) Successful in 4m31s
Build App / sync-to-github (push) Successful in 10s
Replaces the inline Yes/No confirmation with a proper ConfirmRemoveModal
dialog, consistent with other modal patterns in the app (EnvVarsModal, etc.).
Supports Escape key and overlay click to dismiss.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 08:14:44 -08:00
ab16ac11e7 Add bash shell tab and file manager for running containers
All checks were successful
Build App / build-macos (push) Successful in 2m23s
Build App / build-windows (push) Successful in 3m52s
Build App / build-linux (push) Successful in 4m53s
Build App / sync-to-github (push) Successful in 12s
Adds two new features for running project containers:

1. Bash Shell Tab: A "Shell" button on running projects opens a plain
   bash -l session instead of Claude Code, useful for direct container
   inspection, package installation, and debugging. Tab labels show
   "(bash)" suffix to distinguish from Claude sessions.

2. File Manager: A "Files" button opens a modal file browser for
   navigating container directories, downloading files to the host,
   and uploading files from the host. Supports breadcrumb navigation
   and works with any path including those outside mounted projects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 06:32:53 -08:00
429acd2fb5 Add Mission Control integration with per-project toggle
All checks were successful
Build App / build-macos (push) Successful in 2m49s
Build App / build-windows (push) Successful in 3m32s
Build App / build-linux (push) Successful in 4m29s
Build Container / build-container (push) Successful in 56s
Build App / sync-to-github (push) Successful in 9s
When enabled, the entrypoint clones mission-control into ~/mission-control
(persisted on the home volume) and symlinks it to /workspace/mission-control.
Flight Control global and project instructions are programmatically appended
to CLAUDE.md. Container recreation is triggered on toggle change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:32:04 -08:00
c853f2676d Add tooltip hint for double-click to rename project name
All checks were successful
Build App / build-macos (push) Successful in 3m29s
Build App / build-windows (push) Successful in 3m55s
Build App / build-linux (push) Successful in 4m43s
Build App / sync-to-github (push) Successful in 9s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:27:24 -08:00
090aad6bc6 Fix project rename, remove confirmation, and auth mode change bugs
All checks were successful
Build App / build-macos (push) Successful in 2m43s
Build App / build-windows (push) Successful in 4m31s
Build App / build-linux (push) Successful in 4m41s
Build App / sync-to-github (push) Successful in 9s
- Add double-click-to-rename on project names in the sidebar
- Replace window.confirm() with inline React confirmation for project
  removal (confirm dialog didn't block in Tauri webview)
- Add serde(default) to skip_serializing fields in Rust models so
  deserialization doesn't fail when frontend omits secret fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:39:34 -08:00
c023d80c86 Hide mic toggle UI until upstream /voice WSL detection is fixed
All checks were successful
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 3m57s
Build App / build-linux (push) Successful in 5m17s
Build App / sync-to-github (push) Successful in 11s
Claude Code's /voice command incorrectly detects containers running
on WSL2 hosts as unsupported WSL environments. Remove the mic button
from project cards and microphone settings from the settings panel,
but keep useVoice hook and MicrophoneSettings component for re-enabling
once the upstream issue is resolved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:12:35 -08:00
33f02e65c0 Move mic button from terminal overlay to project action buttons
All checks were successful
Build App / build-macos (push) Successful in 2m53s
Build App / build-windows (push) Successful in 3m26s
Build App / build-linux (push) Successful in 5m59s
Build App / sync-to-github (push) Successful in 11s
Relocates the voice/mic toggle from a floating overlay on the terminal
view to the project command row (alongside Stop, Terminal, Config) so
it no longer blocks access to the terminal window.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:58:03 -08:00
c5e28f9caa feat: add microphone selection to settings
All checks were successful
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m42s
Build App / sync-to-github (push) Successful in 18s
Adds a dropdown in Settings to choose which audio input device to
use for voice mode. Enumerates devices via the browser's
mediaDevices API and persists the selection in AppSettings.
The useVoice hook passes the selected deviceId to getUserMedia().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:15:47 -08:00
86176d8830 feat: add voice mode support via mic passthrough to container
Some checks failed
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m24s
Build App / sync-to-github (push) Has been cancelled
Build App / build-linux (push) Has been cancelled
Build Container / build-container (push) Successful in 54s
Enables Claude Code's /voice command inside Docker containers by
capturing microphone audio in the Tauri webview and streaming it
into the container via a FIFO pipe.

Container: fake rec/arecord shims read PCM from a FIFO instead of
a real mic. Audio bridge exec writes PCM from Tauri into the FIFO.
Frontend: getUserMedia() + AudioWorklet captures 16kHz mono PCM
and streams it to the container via invoke("send_audio_data").
UI: "Mic Off/On" toggle button in the terminal view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 06:11:33 -08:00
58a10c65e9 feat: add OSC 52 clipboard support for container-to-host copy
All checks were successful
Build App / build-macos (push) Successful in 2m24s
Build App / build-windows (push) Successful in 3m57s
Build App / build-linux (push) Successful in 8m28s
Build Container / build-container (push) Successful in 1m47s
Build App / sync-to-github (push) Successful in 12s
Programs inside the container (e.g. Claude Code's "hit c to copy") can
now write to the host system clipboard. A shell script shim installed as
xclip/xsel/pbcopy emits OSC 52 escape sequences, which the xterm.js
frontend intercepts and forwards to navigator.clipboard.writeText().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 05:47:42 -08:00
d56c6e3845 fix: validate AWS SSO session before launching Claude for Bedrock Profile auth
All checks were successful
Build App / build-macos (push) Successful in 2m20s
Build App / build-windows (push) Successful in 3m21s
Build App / build-linux (push) Successful in 5m41s
Build Container / build-container (push) Successful in 1m27s
Build App / sync-to-github (push) Successful in 12s
When using AWS Profile auth (SSO) with Bedrock, expired SSO sessions
caused Claude Code to spin indefinitely. Three root causes fixed:

1. Mount host .aws at /tmp/.host-aws (read-only) and copy to
   /home/claude/.aws in entrypoint, mirroring the SSH key pattern.
   This gives AWS CLI writable sso/cache and cli/cache directories.

2. For Bedrock Profile projects, wrap the claude command in a bash
   script that validates credentials via `aws sts get-caller-identity`
   before launch. If SSO session is expired, runs `aws sso login`
   with the auth URL visible and clickable in the terminal.

3. Non-SSO profiles with bad creds get a warning but Claude still
   starts. Non-Bedrock projects are unaffected.

Note: existing containers need a rebuild to pick up the new mount path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:41:42 -08:00
574fca633a fix: sync build artifacts to GitHub instead of empty source archives
All checks were successful
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 3m21s
Build App / build-linux (push) Successful in 4m37s
Build App / sync-to-github (push) Successful in 11s
Move GitHub release sync into build-app.yml as a final sync-to-github
job that runs after all 3 platform builds complete. This eliminates the
race condition where sync-release.yml triggered before artifacts were
uploaded to Gitea. The old sync-release.yml is changed to manual-only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:57:27 -08:00
e07c0e6150 fix: use SHA-256 for container fingerprints instead of DefaultHasher
All checks were successful
Build App / build-macos (push) Successful in 2m23s
Build App / build-windows (push) Successful in 3m17s
Build App / build-linux (push) Successful in 4m30s
Sync Release to GitHub / sync-release (release) Successful in 2s
DefaultHasher is not stable across Rust compiler versions or binary
rebuilds, causing unnecessary container recreations on every app update.
Replace with SHA-256 for deterministic, cross-build-stable fingerprints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:42:06 -08:00
62 changed files with 4045 additions and 327 deletions

View File

@@ -5,11 +5,14 @@ on:
branches: [main]
paths:
- "app/**"
- "VERSION"
- ".gitea/workflows/build-app.yml"
pull_request:
branches: [main]
paths:
- "app/**"
- "VERSION"
- ".gitea/workflows/build-app.yml"
workflow_dispatch:
env:
@@ -17,8 +20,43 @@ env:
REPO: ${{ gitea.repository }}
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: Fetch all tags
run: git fetch --tags
- name: Compute version from VERSION file and tags
id: version
run: |
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
echo "Major.Minor: ${MAJOR_MINOR}"
# 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)
if [ -n "$LATEST_TAG" ]; then
echo "Latest matching tag: ${LATEST_TAG}"
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
else
echo "No matching tag found for v${MAJOR_MINOR}.*, using total commit count"
PATCH=$(git rev-list --count HEAD)
fi
VERSION="${MAJOR_MINOR}.${PATCH}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed version: ${VERSION}"
build-linux:
runs-on: ubuntu-latest
needs: [compute-version]
steps:
- name: Install Node.js 22
run: |
@@ -51,17 +89,9 @@ jobs:
with:
fetch-depth: 0
- name: Compute version
id: version
run: |
COMMIT_COUNT=$(git rev-list --count HEAD)
VERSION="0.1.${COMMIT_COUNT}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed version: ${VERSION}"
- name: Set app version
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
VERSION="${{ needs.compute-version.outputs.version }}"
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
@@ -130,7 +160,7 @@ jobs:
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
TAG="v${{ steps.version.outputs.VERSION }}"
TAG="v${{ needs.compute-version.outputs.version }}"
# Create release
curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
@@ -153,6 +183,7 @@ jobs:
build-macos:
runs-on: macos-latest
needs: [compute-version]
steps:
- name: Install Node.js 22
run: |
@@ -180,17 +211,9 @@ jobs:
with:
fetch-depth: 0
- name: Compute version
id: version
run: |
COMMIT_COUNT=$(git rev-list --count HEAD)
VERSION="0.1.${COMMIT_COUNT}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed version: ${VERSION}"
- name: Set app version
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
VERSION="${{ needs.compute-version.outputs.version }}"
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
@@ -240,12 +263,12 @@ jobs:
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
TAG="v${{ steps.version.outputs.VERSION }}-mac"
TAG="v${{ needs.compute-version.outputs.version }}-mac"
# Create release
curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ steps.version.outputs.VERSION }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
-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
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
echo "Release ID: ${RELEASE_ID}"
@@ -263,6 +286,7 @@ jobs:
build-windows:
runs-on: windows-latest
needs: [compute-version]
defaults:
run:
shell: cmd
@@ -272,18 +296,10 @@ jobs:
with:
fetch-depth: 0
- name: Compute version
id: version
run: |
for /f %%i in ('git rev-list --count HEAD') do set "COMMIT_COUNT=%%i"
set "VERSION=0.1.%COMMIT_COUNT%"
echo VERSION=%VERSION%>> %GITHUB_OUTPUT%
echo Computed version: %VERSION%
- name: Set app version
shell: powershell
run: |
$version = "${{ steps.version.outputs.VERSION }}"
$version = "${{ needs.compute-version.outputs.version }}"
(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
@@ -364,9 +380,9 @@ jobs:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
COMMIT_SHA: ${{ gitea.sha }}
run: |
set "TAG=v${{ steps.version.outputs.VERSION }}-win"
set "TAG=v${{ needs.compute-version.outputs.version }}-win"
echo Creating release %TAG%...
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Triple-C v${{ steps.version.outputs.VERSION }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found
:found
echo Release ID: %RELEASE_ID%
@@ -374,3 +390,123 @@ jobs:
echo Uploading %%~nxf...
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/octet-stream" --data-binary "@%%f" "%GITEA_URL%/api/v1/repos/%REPO%/releases/%RELEASE_ID%/assets?name=%%~nxf"
)
create-tag:
runs-on: ubuntu-latest
needs: [compute-version, build-linux, build-macos, build-windows]
if: gitea.event_name == 'push'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create version tag
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
VERSION="${{ needs.compute-version.outputs.version }}"
TAG="v${VERSION}"
echo "Creating tag ${TAG}..."
# Create annotated tag via Gitea API
curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"target\": \"${{ gitea.sha }}\", \"message\": \"Release ${TAG}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/tags" || echo "Tag may already exist (created by release)"
echo "Tag ${TAG} created successfully"
sync-to-github:
runs-on: ubuntu-latest
needs: [compute-version, build-linux, build-macos, build-windows]
if: gitea.event_name == 'push'
env:
GH_PAT: ${{ secrets.GH_PAT }}
GITHUB_REPO: shadowdao/triple-c
steps:
- name: Download artifacts from Gitea releases
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
VERSION: ${{ needs.compute-version.outputs.version }}
run: |
set -e
mkdir -p artifacts
# Download assets from all 3 platform releases
for TAG_SUFFIX in "" "-mac" "-win"; do
TAG="v${VERSION}${TAG_SUFFIX}"
echo "==> Fetching assets for release ${TAG}..."
RELEASE_JSON=$(curl -sf \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}" 2>/dev/null || echo "{}")
echo "$RELEASE_JSON" | jq -r '.assets[]? | "\(.name) \(.browser_download_url)"' | while read -r NAME URL; do
[ -z "$NAME" ] && continue
echo " Downloading ${NAME}..."
curl -sfL \
-H "Authorization: token ${TOKEN}" \
-o "artifacts/${NAME}" \
"$URL"
done
done
echo "==> All downloaded artifacts:"
ls -la artifacts/
- name: Create GitHub release and upload artifacts
env:
VERSION: ${{ needs.compute-version.outputs.version }}
COMMIT_SHA: ${{ gitea.sha }}
run: |
set -e
TAG="v${VERSION}"
echo "==> Creating unified release ${TAG} on GitHub..."
# Delete existing release if present (idempotent re-runs)
EXISTING=$(curl -sf \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" 2>/dev/null || echo "{}")
EXISTING_ID=$(echo "$EXISTING" | jq -r '.id // empty')
if [ -n "$EXISTING_ID" ]; then
echo " Deleting existing GitHub release ${TAG} (id: ${EXISTING_ID})..."
curl -sf -X DELETE \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${EXISTING_ID}"
fi
RESPONSE=$(curl -sf -X POST \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/json" \
"https://api.github.com/repos/${GITHUB_REPO}/releases" \
-d "{
\"tag_name\": \"${TAG}\",
\"name\": \"Triple-C ${TAG}\",
\"body\": \"Automated build from commit ${COMMIT_SHA}\n\nIncludes Linux, macOS, and Windows artifacts.\",
\"draft\": false,
\"prerelease\": false
}")
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
echo "==> Upload URL: ${UPLOAD_URL}"
for file in artifacts/*; do
[ -f "$file" ] || continue
FILENAME=$(basename "$file")
MIME="application/octet-stream"
echo "==> Uploading ${FILENAME}..."
curl -sf -X POST \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: ${MIME}" \
--data-binary "@${file}" \
"${UPLOAD_URL}?name=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "${FILENAME}")"
done
echo "==> GitHub release sync complete."

View File

@@ -5,10 +5,12 @@ on:
branches: [main]
paths:
- "container/**"
- ".gitea/workflows/build.yml"
pull_request:
branches: [main]
paths:
- "container/**"
- ".gitea/workflows/build.yml"
env:
REGISTRY: repo.anhonesthost.net

View File

@@ -1,8 +1,7 @@
name: Sync Release to GitHub
on:
release:
types: [published]
workflow_dispatch:
jobs:
sync-release:

View File

@@ -72,7 +72,7 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
- `exec.rs` — PTY exec sessions with bidirectional stdin/stdout streaming
- `image.rs` — Image build/pull with progress streaming
- **`models/`** — Serde structs (`Project`, `AuthMode`, `BedrockConfig`, `ContainerInfo`, `AppSettings`). These define the IPC contract with the frontend.
- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `LiteLlmConfig`, `ContainerInfo`, `AppSettings`). 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`
### Container (`container/`)
@@ -90,6 +90,8 @@ Containers use a **stop/start** model (not create/destroy). Installed packages p
Per-project, independently configured:
- **Anthropic (OAuth)** — `claude login` in terminal, token persists in config volume
- **AWS Bedrock** — Static keys, profile, or bearer token injected as env vars
- **Ollama** — Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`)
- **LiteLLM** — Connect through a LiteLLM proxy gateway via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` to access 100+ model providers
## Styling

View File

@@ -33,6 +33,8 @@ You need access to Claude Code through one of:
- **Anthropic account** — Sign up at https://claude.ai and use `claude login` (OAuth) inside the terminal
- **AWS Bedrock** — An AWS account with Bedrock access and Claude models enabled
- **Ollama** — A local or remote Ollama server running an Anthropic-compatible model (best-effort support)
- **LiteLLM** — A LiteLLM proxy gateway providing access to 100+ model providers (best-effort support)
---
@@ -65,11 +67,11 @@ Switch to the **Projects** tab in the sidebar and click the **+** button.
### 3. Start the Container
Select your project in the sidebar and click **Start**. The status dot changes from gray (stopped) to orange (starting) to green (running).
Select your project in the sidebar and click **Start**. A progress modal appears showing real-time status as the container starts. The status dot changes from gray (stopped) to orange (starting) to green (running). The modal auto-closes on success.
### 4. Open a Terminal
Click the **Terminal** button (highlighted in accent color) to open an interactive terminal session. A new tab appears in the top bar and an xterm.js terminal loads in the main area.
Click the **Terminal** button to open an interactive terminal session. A new tab appears in the top bar and an xterm.js terminal loads in the main area.
Claude Code launches automatically with `--dangerously-skip-permissions` inside the sandboxed container.
@@ -84,10 +86,24 @@ Claude Code launches automatically with `--dangerously-skip-permissions` inside
**AWS Bedrock:**
1. Stop the container first (settings can only be changed while stopped).
2. In the project card, switch the auth mode to **Bedrock**.
2. In the project card, switch the backend to **Bedrock**.
3. Expand the **Config** panel and fill in your AWS credentials (see [AWS Bedrock Configuration](#aws-bedrock-configuration) below).
4. Start the container again.
**Ollama:**
1. Stop the container first (settings can only be changed while stopped).
2. In the project card, switch the backend to **Ollama**.
3. Expand the **Config** panel and set the base URL of your Ollama server (defaults to `http://host.docker.internal:11434` for a local instance). Optionally set a model ID.
4. Start the container again.
**LiteLLM:**
1. Stop the container first (settings can only be changed while stopped).
2. In the project card, switch the backend to **LiteLLM**.
3. Expand the **Config** panel and set the base URL of your LiteLLM proxy (defaults to `http://host.docker.internal:4000`). Optionally set an API key and model ID.
4. Start the container again.
---
## The Interface
@@ -99,16 +115,16 @@ Claude Code launches automatically with `--dangerously-skip-permissions` inside
│ Sidebar │ │
│ │ Terminal View │
│ Projects │ (xterm.js) │
│ MCP │ │
│ Settings │ │
│ │ │
├────────────┴────────────────────────────────────────┤
│ StatusBar X projects · X running · X terminals │
└─────────────────────────────────────────────────────┘
```
- **TopBar** — Terminal tabs for switching between sessions. Status dots on the right show Docker connection (green = connected) and image availability (green = ready).
- **Sidebar** — Toggle between the **Projects** list and **Settings** panel.
- **Terminal View** — Interactive terminal powered by xterm.js with WebGL rendering.
- **TopBar** — Terminal tabs for switching between sessions. Bash shell tabs show a "(bash)" suffix. Status dots on the right show Docker connection (green = connected) and image availability (green = ready).
- **Sidebar** — Toggle between the **Projects** list, **MCP** server configuration, and **Settings** panel.
- **Terminal View** — Interactive terminal powered by xterm.js with WebGL rendering. Includes a **Jump to Current** button that appears when you scroll up, so you can quickly return to the latest output.
- **StatusBar** — Counts of total projects, running containers, and open terminal sessions.
---
@@ -134,11 +150,17 @@ Select a project in the sidebar to see its action buttons:
|--------|---------------|--------------|
| **Start** | Stopped | Creates (if needed) and starts the container |
| **Stop** | Running | Stops the container but preserves its state |
| **Terminal** | Running | Opens a new terminal session in this container |
| **Terminal** | Running | Opens a new Claude Code terminal session |
| **Shell** | Running | Opens a bash login shell in the container (no Claude Code) |
| **Files** | Running | Opens the file manager to browse, download, and upload files |
| **Reset** | Stopped | Destroys and recreates the container from scratch |
| **Config** | Always | Toggles the configuration panel |
| **Remove** | Stopped | Deletes the project and its container (with confirmation) |
### Renaming a Project
Double-click the project name in the sidebar to rename it inline. Press **Enter** to confirm or **Escape** to cancel.
### Container Lifecycle
Containers use a **stop/start** model. When you stop a container, everything inside it is preserved — installed packages, modified files, downloaded tools. Starting it again resumes where you left off.
@@ -147,6 +169,10 @@ Containers use a **stop/start** model. When you stop a container, everything ins
Only **Remove** deletes everything, including the config volume and any stored credentials.
### Container Progress Feedback
When starting, stopping, or resetting a container, a progress modal shows real-time status messages (e.g., "Setting up MCP network...", "Starting MCP containers...", "Creating container..."). If an error occurs, the modal displays the error with a **Close** button. A **Force Stop** option is available if the operation stalls. The modal auto-closes on success.
---
## Project Configuration
@@ -177,6 +203,19 @@ When enabled, the host Docker socket is mounted into the container so Claude Cod
> Toggling this requires stopping and restarting the container to take effect.
### Mission Control
Toggle **Mission Control** to integrate [Flight Control](https://github.com/msieurthenardier/mission-control) — an AI-first development methodology — into the project. When enabled:
- The Flight Control repository is automatically cloned into the container
- Flight Control skills are installed to Claude Code's skill directory (`~/.claude/skills/`)
- Project instructions are appended with Flight Control workflow guidance
- The repository is symlinked at `/workspace/mission-control`
Available skills include `/mission`, `/flight`, `/leg`, `/agentic-workflow`, `/flight-debrief`, `/mission-debrief`, `/daily-briefing`, and `/init-project`.
> This setting can only be changed when the container is stopped. Toggling it triggers a container recreation on the next start.
### Environment Variables
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.
@@ -188,8 +227,8 @@ Click **Edit** to open the environment variables modal. Add key-value pairs that
Click **Edit** to map host ports to container ports. This is useful when Claude Code starts a web server or other service inside the container and you want to access it from your host browser.
Each mapping specifies:
- **Host Port** — The port on your machine (165535)
- **Container Port** — The port inside the container (165535)
- **Host Port** — The port on your machine (1-65535)
- **Container Port** — The port inside the container (1-65535)
- **Protocol** — TCP (default) or UDP
### Claude Instructions
@@ -198,9 +237,131 @@ Click **Edit** to write per-project instructions for Claude Code. These are writ
---
## MCP Servers (Beta)
Triple-C supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers, which extend Claude Code with access to external tools and data sources. MCP servers are configured in a **global library** and **enabled per-project**.
### How It Works
There are two dimensions to MCP server configuration:
| | **Manual** (no Docker image) | **Docker** (Docker image specified) |
|---|---|---|
| **Stdio** | Command runs inside the project container | Command runs in a separate MCP container via `docker exec` |
| **HTTP** | Connects to a URL you provide | Runs in a separate container, reached by hostname on a shared Docker network |
**Docker images are pulled automatically** if not already present when the project starts.
### Accessing MCP Configuration
Click the **MCP** tab in the sidebar to open the MCP server library. This is where you define all available MCP servers.
### Adding an MCP Server
1. Type a name in the input field and click **Add**.
2. Expand the server card and configure it.
The key decision is whether to set a **Docker Image**:
- **With Docker image** — The MCP server runs in its own isolated container. Best for servers that need specific dependencies or system-level packages.
- **Without Docker image** (manual) — The command runs directly inside your project container. Best for lightweight npx-based servers that just need Node.js.
Then choose the **Transport Type**:
- **Stdio** — The MCP server communicates over stdin/stdout. This is the most common type.
- **HTTP** — The MCP server exposes an HTTP endpoint (streamable HTTP transport).
### Configuration Examples
#### Example 1: Filesystem Server (Stdio, Manual)
A simple npx-based server that runs inside the project container. No Docker image needed since Node.js is already installed.
| Field | Value |
|-------|-------|
| **Docker Image** | *(empty)* |
| **Transport** | Stdio |
| **Command** | `npx` |
| **Arguments** | `-y @modelcontextprotocol/server-filesystem /workspace` |
This gives Claude Code access to browse and read files via MCP. The command runs directly inside the project container using the pre-installed Node.js.
#### Example 2: GitHub Server (Stdio, Manual)
Another npx-based server, with an environment variable for authentication.
| Field | Value |
|-------|-------|
| **Docker Image** | *(empty)* |
| **Transport** | Stdio |
| **Command** | `npx` |
| **Arguments** | `-y @modelcontextprotocol/server-github` |
| **Environment Variables** | `GITHUB_PERSONAL_ACCESS_TOKEN` = `ghp_your_token` |
#### Example 3: Custom MCP Server (HTTP, Docker)
An MCP server packaged as a Docker image that exposes an HTTP endpoint.
| Field | Value |
|-------|-------|
| **Docker Image** | `myregistry/my-mcp-server:latest` |
| **Transport** | HTTP |
| **Container Port** | `8080` |
| **Environment Variables** | `API_KEY` = `your_key` |
Triple-C will:
1. Pull the image automatically if not present
2. Start the container on the project's bridge network
3. Configure Claude Code to reach it at `http://triple-c-mcp-{id}:8080/mcp`
The hostname is the MCP container's name on the Docker network — **not** `localhost`.
#### Example 4: Database Server (Stdio, Docker)
An MCP server that needs its own runtime environment, communicating over stdio.
| Field | Value |
|-------|-------|
| **Docker Image** | `mcp/postgres-server:latest` |
| **Transport** | Stdio |
| **Command** | `node` |
| **Arguments** | `dist/index.js` |
| **Environment Variables** | `DATABASE_URL` = `postgresql://user:pass@host:5432/db` |
Triple-C will:
1. Pull the image and start it on the project network
2. Configure Claude Code to communicate via `docker exec -i triple-c-mcp-{id} node dist/index.js`
3. Automatically enable Docker socket access on the project container (required for `docker exec`)
### Enabling MCP Servers Per-Project
In a project's configuration panel (click **Config**), the **MCP Servers** section shows checkboxes for all globally defined servers. Toggle each server on or off for that project. Changes take effect on the next container start.
### How Docker-Based MCP Works
When a project with Docker-based MCP servers starts:
1. Missing Docker images are **automatically pulled** (progress shown in the progress modal)
2. A dedicated **bridge network** is created for the project (`triple-c-net-{projectId}`)
3. Each enabled Docker MCP server gets its own container on that network
4. The main project container is connected to the same network
5. MCP server configuration is written to `~/.claude.json` inside the container
**Networking**: Docker-based MCP containers are reached by their container name as a hostname (e.g., `triple-c-mcp-{serverId}`), not by `localhost`. Docker DNS resolves these names automatically on the shared bridge network.
**Stdio + Docker**: The project container uses `docker exec` to communicate with the MCP container over stdin/stdout. This automatically enables Docker socket access on the project container.
**HTTP + Docker**: The project container connects to the MCP container's HTTP endpoint using the container hostname and port (e.g., `http://triple-c-mcp-{serverId}:3000/mcp`).
**Manual (no Docker image)**: Stdio commands run directly inside the project container. HTTP URLs connect to wherever you point them (could be an external service or something running on the host).
### Configuration Change Detection
MCP server configuration is tracked via SHA-256 fingerprints stored as Docker labels. If you add, remove, or modify MCP servers for a project, the container is automatically recreated on the next start to apply the new configuration. The container filesystem is snapshotted first, so installed packages are preserved.
---
## AWS Bedrock Configuration
To use Claude via AWS Bedrock instead of Anthropic's API, switch the auth mode to **Bedrock** on the project card.
To use Claude via AWS Bedrock instead of Anthropic's API, switch the backend to **Bedrock** on the project card.
### Authentication Methods
@@ -227,6 +388,41 @@ Per-project settings always override these global defaults.
---
## Ollama Configuration
To use Claude Code with a local or remote Ollama server, switch the backend to **Ollama** on the project card.
### Settings
- **Base URL** — The URL of your Ollama server. Defaults to `http://host.docker.internal:11434`, which reaches a locally running Ollama instance from inside the container. For a remote server, use its IP or hostname (e.g., `http://192.168.1.100:11434`).
- **Model ID** — Optional. Override the model to use (e.g., `qwen3.5:27b`).
### How It Works
Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server instead of Anthropic's API. The `ANTHROPIC_AUTH_TOKEN` is set to `ollama` (required by Claude Code but not used for actual authentication).
> **Note:** Ollama support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models.
---
## LiteLLM Configuration
To use Claude Code through a [LiteLLM](https://docs.litellm.ai/) proxy gateway, switch the backend to **LiteLLM** on the project card. LiteLLM supports 100+ model providers (OpenAI, Gemini, Anthropic, and more) through a single proxy.
### Settings
- **Base URL** — The URL of your LiteLLM proxy. Defaults to `http://host.docker.internal:4000` for a locally running proxy.
- **API Key** — Optional. The API key for your LiteLLM proxy, if authentication is required. Stored securely in your OS keychain.
- **Model ID** — Optional. Override the model to use.
### How It Works
Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your LiteLLM proxy. If an API key is provided, it is set as `ANTHROPIC_AUTH_TOKEN`.
> **Note:** LiteLLM support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected when routing to non-Anthropic models through the proxy.
---
## Settings
Access global settings via the **Settings** tab in the sidebar.
@@ -264,7 +460,11 @@ When an update is available, a pulsing **Update** button appears in the top bar.
### 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.
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.
### Bash Shell Sessions
In addition to Claude Code terminals, you can open a plain **bash login shell** in any running container by clicking the **Shell** button. This is useful for manual inspection, package installation, debugging, or running commands that don't need Claude Code.
### URL Detection
@@ -272,9 +472,28 @@ When Claude Code prints a long URL (e.g., during `claude login`), Triple-C detec
Shorter URLs in terminal output are also clickable directly.
### Clipboard Support (OSC 52)
Programs inside the container can copy text to your host clipboard. When a container program uses `xclip`, `xsel`, or `pbcopy`, the text is transparently forwarded to your host clipboard via OSC 52 escape sequences. No additional configuration is required — this works out of the box.
### Image Paste
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container and the file path is injected into the terminal input so Claude Code can reference it.
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container as `/tmp/clipboard_<timestamp>.png` and the file path is injected into the terminal input so Claude Code can reference it. A toast notification confirms the upload.
### Jump to Current
When you scroll up in the terminal to review previous output, a **Jump to Current** button appears in the bottom-right corner. Click it to scroll back to the latest output.
### File Manager
Click the **Files** button on a running project to open the file manager modal. You can:
- **Browse** the container filesystem starting from `/workspace`, with breadcrumb navigation
- **Download** any file to your host machine via the download button on each file entry
- **Upload** files from your host into the current container directory
- **Refresh** the directory listing at any time
The file manager shows file names, sizes, and modification dates.
### Terminal Rendering
@@ -356,6 +575,8 @@ The sandbox container (Ubuntu 24.04) comes pre-installed with:
| build-essential | — | C/C++ compiler toolchain |
| openssh-client | — | SSH for git and remote access |
The container also includes **clipboard shims** (`xclip`, `xsel`, `pbcopy`) that forward copy operations to the host via OSC 52, and an **audio shim** (`rec`, `arecord`) for future voice mode support.
You can install additional tools at runtime with `sudo apt install`, `pip install`, `npm install -g`, etc. Installed packages persist across container stops (but not across resets).
---
@@ -378,7 +599,7 @@ You can install additional tools at runtime with `sudo apt install`, `pip instal
- Check that the Docker image is "Ready" in Settings.
- Verify that the mounted folder paths exist on your host.
- Look at the error message displayed in red below the project card.
- Look at the error message displayed in the progress modal.
### OAuth Login URL Not Opening
@@ -394,4 +615,10 @@ You can install additional tools at runtime with `sudo apt install`, `pip instal
### Settings Won't Save
- Most project settings can only be changed when the container is **stopped**. Stop the container first, make your changes, then start it again.
- Some changes (like toggling Docker access or changing mounted folders) trigger an automatic container recreation on the next start.
- Some changes (like toggling Docker access, Mission Control, or changing mounted folders) trigger an automatic container recreation on the next start.
### MCP Containers Not Starting
- Ensure the Docker image for the MCP server exists (pull it first if needed).
- Check that Docker socket access is available (stdio + Docker MCP servers auto-enable this).
- Try resetting the project container to force a clean recreation.

View File

@@ -27,10 +27,10 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
### Container Lifecycle
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
3. **Terminal**: `docker exec` launches Claude Code with a PTY
4. **Stop**: Container halted (filesystem persists in named volume)
5. **Restart**: Existing container restarted; recreated if settings changed (e.g., Docker access toggled)
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, sets up MCP servers
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
5. **Restart**: Existing container restarted; recreated if settings changed (detected via SHA-256 fingerprint)
6. **Reset**: Container removed and recreated from scratch (named volume preserved)
### Mounts
@@ -41,14 +41,18 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Persists across container recreation |
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
| `/var/run/docker.sock` | Host Docker socket | Bind | Only if "Allow container spawning" is ON |
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON, or auto-enabled by stdio+Docker MCP servers |
### Authentication Modes
Each project can independently use one of:
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets.
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token).
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Optional model override.
- **LiteLLM**: Connect through a LiteLLM proxy gateway via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` to access 100+ model providers. API key stored securely in OS keychain.
> **Note:** Ollama and LiteLLM support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
### Container Spawning (Sibling Containers)
@@ -56,6 +60,31 @@ When "Allow container spawning" is enabled per-project, the host Docker socket i
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
### MCP Server Architecture
Triple-C supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers as a Beta feature. MCP servers extend Claude Code with external tools and data sources.
**Modes**: Each MCP server operates in one of four modes based on transport type and whether a Docker image is specified:
| Mode | Where It Runs | How It Communicates |
|------|--------------|---------------------|
| Stdio + Manual | Inside the project container | Direct stdin/stdout (e.g., `npx -y @mcp/server`) |
| Stdio + Docker | Separate MCP container | `docker exec -i <mcp-container> <command>` from the project container |
| HTTP + Manual | External / user-provided | Connects to the URL you specify |
| HTTP + Docker | Separate MCP container | `http://<mcp-container>:<port>/mcp` via Docker DNS on a shared bridge network |
**Key behaviors**:
- **Global library**: MCP servers are defined globally in the MCP sidebar tab and stored in `mcp_servers.json`
- **Per-project toggles**: Each project enables/disables individual servers via checkboxes
- **Auto-pull**: Docker images for MCP servers are pulled automatically if not present when the project starts
- **Docker networking**: Docker-based MCP containers run on a per-project bridge network (`triple-c-net-{projectId}`), reachable by container name — not localhost
- **Auto-detection**: Config changes are detected via SHA-256 fingerprints and trigger automatic container recreation
- **Config injection**: MCP server configuration is written to `~/.claude.json` inside the container via the `MCP_SERVERS_JSON` environment variable, merged by the entrypoint using `jq`
### Mission Control Integration
Optional per-project integration with [Flight Control](https://github.com/msieurthenardier/mission-control) — an AI-first development methodology. When enabled, the repo is cloned into the container, skills are installed, and workflow instructions are injected into CLAUDE.md.
### Docker Socket Path
The socket path is OS-aware:
@@ -73,19 +102,34 @@ Users can override this in Settings via the global `docker_socket_path` option.
| `app/src/components/layout/TopBar.tsx` | Terminal tabs + Docker/Image status indicators |
| `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/projects/ProjectCard.tsx` | Project config, auth mode, action buttons |
| `app/src/components/projects/ProjectCard.tsx` | Project config, backend selector, action buttons |
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
| `app/src/components/settings/SettingsPanel.tsx` | API key, Docker, AWS settings |
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection |
| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions |
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, inspection |
| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions for terminal interaction |
| `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/mcp/McpPanel.tsx` | MCP server library (global configuration) |
| `app/src/components/mcp/McpServerCard.tsx` | Individual MCP server configuration card |
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, and global settings |
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, image paste |
| `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/useFileManager.ts` | File manager operations (list, download, upload) |
| `app/src/hooks/useMcpServers.ts` | MCP server CRUD operations |
| `app/src/hooks/useVoice.ts` | Voice mode audio capture (currently hidden) |
| `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/image.rs` | Image building/pulling |
| `app/src-tauri/src/docker/network.rs` | Per-project bridge networks for MCP containers |
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
| `app/src-tauri/src/models/project.rs` | Project struct (auth mode, Docker access, etc.) |
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS) |
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools |
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config |
| `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/models/project.rs` | Project struct (backend, Docker access, 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/app_settings.rs` | Global settings (image source, Docker socket, AWS, microphone) |
| `app/src-tauri/src/storage/mcp_store.rs` | MCP server persistence (JSON with atomic writes) |
| `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/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
## CSS / Styling Notes
@@ -100,4 +144,6 @@ Users can override this in Settings via the global `docker_socket_path` option.
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential
**Shims**: `xclip`/`xsel`/`pbcopy` (OSC 52 clipboard forwarding), `rec`/`arecord` (audio FIFO for voice mode)
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)

View File

@@ -154,13 +154,12 @@ The `.claude` configuration directory uses a **named Docker volume** (`triple-c-
### Authentication Modes
Each project independently chooses one of three authentication methods:
Each project independently chooses one of two authentication methods:
| Mode | How It Works | When to Use |
|------|-------------|-------------|
| **Login (OAuth)** | User runs `claude login` or `/login` inside the terminal. OAuth URL opens in host browser via the web links addon. Token persists in the `.claude` config volume. | Personal use, interactive sessions |
| **API Key** | Key stored in OS keychain, injected as `ANTHROPIC_API_KEY` env var at container creation. | Automated workflows, team-shared keys |
| **AWS Bedrock** | Per-project AWS credentials (static, profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only. | Enterprise environments using Bedrock |
| **Anthropic (OAuth)** | User runs `claude login` or `/login` inside the terminal. OAuth URL opens in host browser via URL detection. Token persists in the `.claude` config volume. | Default — personal and team use |
| **AWS Bedrock** | Per-project AWS credentials (static keys, profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only. | Enterprise environments using Bedrock |
### UID/GID Remapping
@@ -213,13 +212,26 @@ The `TerminalView` component works around this with a **URL accumulator**:
```
triple-c/
├── LICENSE # MIT
├── README.md # Architecture overview
├── TECHNICAL.md # This document
├── Triple-C.md # Project overview
├── HOW-TO-USE.md # User guide
├── BUILDING.md # Build instructions
├── CLAUDE.md # Claude Code instructions
├── container/
│ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code
── entrypoint.sh # UID/GID remap, SSH setup, git config
── entrypoint.sh # UID/GID remap, SSH setup, git config, MCP injection
│ ├── osc52-clipboard # Clipboard shim (xclip/xsel/pbcopy via OSC 52)
│ ├── audio-shim # Audio capture shim (rec/arecord via FIFO)
│ ├── triple-c-scheduler # Bash-based cron task system
│ └── triple-c-task-runner # Task execution runner for scheduler
├── .gitea/
│ └── workflows/
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
│ ├── build.yml # Build container image (multi-arch)
│ ├── sync-release.yml # Mirror releases to GitHub
│ └── backfill-releases.yml # Bulk copy releases to GitHub
└── app/ # Tauri v2 desktop application
├── package.json # React, xterm.js, zustand, tailwindcss
@@ -231,22 +243,28 @@ triple-c/
│ ├── App.tsx # Top-level layout
│ ├── index.css # CSS variables, dark theme, scrollbars
│ ├── store/
│ │ └── appState.ts # Zustand store (projects, sessions, UI)
│ │ └── appState.ts # Zustand store (projects, sessions, MCP, UI)
│ ├── hooks/
│ │ ├── useDocker.ts # Docker status, image build
│ │ ├── useDocker.ts # Docker status, image build/pull
│ │ ├── useFileManager.ts # File manager operations
│ │ ├── useMcpServers.ts # MCP server CRUD
│ │ ├── useProjects.ts # Project CRUD operations
│ │ ├── useSettings.ts # API key, app settings
│ │ ── useTerminal.ts # Terminal I/O, resize, session events
│ │ ├── useSettings.ts # App settings
│ │ ── useTerminal.ts # Terminal I/O, resize, session events
│ │ ├── useUpdates.ts # App update checking
│ │ └── useVoice.ts # Voice mode audio capture
│ ├── lib/
│ │ ├── types.ts # TypeScript interfaces matching Rust models
│ │ ├── tauri-commands.ts # Typed invoke() wrappers
│ │ └── constants.ts # App-wide constants
│ └── components/
│ ├── layout/ # Sidebar, TopBar, StatusBar
│ ├── projects/ # ProjectList, ProjectCard, AddProjectDialog
│ ├── terminal/ # TerminalView (xterm.js), TerminalTabs
├── settings/ # ApiKeyInput, DockerSettings, AwsSettings
── containers/ # SiblingContainers
│ ├── mcp/ # McpPanel, McpServerCard
│ ├── projects/ # ProjectCard, ProjectList, AddProjectDialog,
│ # FileManagerModal, ContainerProgressModal, modals
── settings/ # SettingsPanel, DockerSettings, AwsSettings,
│ │ # UpdateDialog
│ └── terminal/ # TerminalView (xterm.js), TerminalTabs, UrlToast
└── src-tauri/ # Rust backend
├── Cargo.toml # Rust dependencies
@@ -256,23 +274,31 @@ triple-c/
└── src/
├── lib.rs # App builder, plugin + command registration
├── main.rs # Entry point
├── logging.rs # Log configuration
├── commands/ # Tauri command handlers
│ ├── docker_commands.rs
│ ├── project_commands.rs
│ ├── settings_commands.rs
── terminal_commands.rs
│ ├── docker_commands.rs # Docker status, image ops
│ ├── file_commands.rs # File manager (list/download/upload)
│ ├── mcp_commands.rs # MCP server CRUD
── project_commands.rs # Start/stop/rebuild containers
│ ├── settings_commands.rs # Settings CRUD
│ ├── terminal_commands.rs # Terminal I/O, resize
│ └── update_commands.rs # App update checking
├── docker/ # Docker API layer
│ ├── client.rs # bollard singleton connection
│ ├── container.rs # Create, start, stop, remove, inspect
│ ├── container.rs # Create, start, stop, remove, fingerprinting
│ ├── exec.rs # PTY exec sessions with bidirectional streaming
│ ├── image.rs # Build from embedded Dockerfile, pull from registry
│ └── sibling.rs # List non-Triple-C containers
│ ├── image.rs # Build from Dockerfile, pull from registry
│ └── network.rs # Per-project bridge networks for MCP
├── models/ # Data structures
│ ├── project.rs # Project, AuthMode, BedrockConfig
── container_config.rs
│ ├── project.rs # Project, Backend, BedrockConfig
── mcp_server.rs # MCP server configuration
│ ├── app_settings.rs # Global settings (image source, AWS, etc.)
│ ├── container_config.rs # Image name resolution
│ └── update_info.rs # Update metadata
└── storage/ # Persistence
├── projects_store.rs # JSON file with atomic writes
├── settings_store.rs # App settings
├── mcp_store.rs # MCP server persistence
├── settings_store.rs # App settings (Tauri plugin-store)
└── secure.rs # OS keychain via keyring
```

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.2

68
app/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "triple-c",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "triple-c",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
@@ -1643,6 +1643,70 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",

View File

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

View File

@@ -0,0 +1,17 @@
class AudioCaptureProcessor extends AudioWorkletProcessor {
process(inputs, outputs, parameters) {
const input = inputs[0];
if (input && input.length > 0 && input[0].length > 0) {
const samples = input[0]; // Float32Array, mono channel
const int16 = new Int16Array(samples.length);
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
this.port.postMessage(int16.buffer, [int16.buffer]);
}
return true;
}
}
registerProcessor('audio-capture-processor', AudioCaptureProcessor);

View File

@@ -4668,7 +4668,7 @@ dependencies = [
[[package]]
name = "triple-c"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"bollard",
"chrono",
@@ -4681,6 +4681,7 @@ dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2",
"tar",
"tauri",
"tauri-build",

View File

@@ -1,6 +1,6 @@
[package]
name = "triple-c"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
[lib]
@@ -30,6 +30,7 @@ fern = { version = "0.7", features = ["date-based"] }
tar = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
iana-time-zone = "0.1"
sha2 = "0.10"
[build-dependencies]
tauri-build = { version = "2", features = [] }

View File

@@ -0,0 +1,30 @@
use tauri::State;
use crate::AppState;
#[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 = project.bedrock_config.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());
log::info!("Running host-side AWS SSO login for profile '{}'", profile);
let status = tokio::process::Command::new("aws")
.args(["sso", "login", "--profile", &profile])
.status()
.await
.map_err(|e| format!("Failed to run aws sso login: {}", e))?;
if !status.success() {
return Err("SSO login failed or was cancelled".to_string());
}
Ok(())
}

View File

@@ -0,0 +1,211 @@
use bollard::container::{DownloadFromContainerOptions, UploadToContainerOptions};
use futures_util::StreamExt;
use serde::Serialize;
use tauri::State;
use crate::docker::client::get_docker;
use crate::docker::exec::exec_oneshot;
use crate::AppState;
#[derive(Debug, Serialize)]
pub struct FileEntry {
pub name: String,
pub path: String,
pub is_directory: bool,
pub size: u64,
pub modified: String,
pub permissions: String,
}
#[tauri::command]
pub async fn list_container_files(
project_id: String,
path: String,
state: State<'_, AppState>,
) -> Result<Vec<FileEntry>, String> {
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
.ok_or_else(|| "Container not running".to_string())?;
let cmd = vec![
"find".to_string(),
path.clone(),
"-mindepth".to_string(),
"1".to_string(),
"-maxdepth".to_string(),
"1".to_string(),
"-printf".to_string(),
"%f\t%y\t%s\t%T@\t%m\n".to_string(),
];
let output = exec_oneshot(container_id, cmd).await?;
let mut entries: Vec<FileEntry> = output
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 5 {
return None;
}
let name = parts[0].to_string();
let is_directory = parts[1] == "d";
let size = parts[2].parse::<u64>().unwrap_or(0);
let modified_epoch = parts[3].parse::<f64>().unwrap_or(0.0);
let permissions = parts[4].to_string();
// Convert epoch to ISO-ish string
let modified = {
let secs = modified_epoch as i64;
let dt = chrono::DateTime::from_timestamp(secs, 0)
.unwrap_or_default();
dt.format("%Y-%m-%d %H:%M:%S").to_string()
};
let entry_path = if path.ends_with('/') {
format!("{}{}", path, name)
} else {
format!("{}/{}", path, name)
};
Some(FileEntry {
name,
path: entry_path,
is_directory,
size,
modified,
permissions,
})
})
.collect();
// Sort: directories first, then alphabetical
entries.sort_by(|a, b| {
b.is_directory
.cmp(&a.is_directory)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(entries)
}
#[tauri::command]
pub async fn download_container_file(
project_id: String,
container_path: String,
host_path: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
.ok_or_else(|| "Container not running".to_string())?;
let docker = get_docker()?;
let mut stream = docker.download_from_container(
container_id,
Some(DownloadFromContainerOptions {
path: container_path.clone(),
}),
);
let mut tar_bytes = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Failed to download file: {}", e))?;
tar_bytes.extend_from_slice(&chunk);
}
// Extract single file from tar archive
let mut archive = tar::Archive::new(&tar_bytes[..]);
let mut found = false;
for entry in archive
.entries()
.map_err(|e| format!("Failed to read tar entries: {}", e))?
{
let mut entry = entry.map_err(|e| format!("Failed to read tar entry: {}", e))?;
let mut contents = Vec::new();
std::io::Read::read_to_end(&mut entry, &mut contents)
.map_err(|e| format!("Failed to read file contents: {}", e))?;
std::fs::write(&host_path, &contents)
.map_err(|e| format!("Failed to write file to host: {}", e))?;
found = true;
break;
}
if !found {
return Err("File not found in tar archive".to_string());
}
Ok(())
}
#[tauri::command]
pub async fn upload_file_to_container(
project_id: String,
host_path: String,
container_dir: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
.ok_or_else(|| "Container not running".to_string())?;
let docker = get_docker()?;
let file_data = std::fs::read(&host_path)
.map_err(|e| format!("Failed to read host file: {}", e))?;
let file_name = std::path::Path::new(&host_path)
.file_name()
.ok_or_else(|| "Invalid file path".to_string())?
.to_string_lossy()
.to_string();
// Build tar archive in memory
let mut tar_buf = Vec::new();
{
let mut builder = tar::Builder::new(&mut tar_buf);
let mut header = tar::Header::new_gnu();
header.set_size(file_data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, &file_name, &file_data[..])
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
builder
.finish()
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
}
docker
.upload_to_container(
container_id,
Some(UploadToContainerOptions {
path: container_dir,
..Default::default()
}),
tar_buf.into(),
)
.await
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
Ok(())
}

View File

@@ -1,4 +1,6 @@
pub mod aws_commands;
pub mod docker_commands;
pub mod file_commands;
pub mod mcp_commands;
pub mod project_commands;
pub mod settings_commands;

View File

@@ -1,7 +1,7 @@
use tauri::{Emitter, State};
use crate::docker;
use crate::models::{container_config, AuthMode, McpServer, Project, ProjectPath, ProjectStatus};
use crate::models::{container_config, Backend, McpServer, Project, ProjectPath, ProjectStatus};
use crate::storage::secure;
use crate::AppState;
@@ -34,6 +34,11 @@ fn store_secrets_for_project(project: &Project) -> Result<(), String> {
secure::store_project_secret(&project.id, "aws-bearer-token", v)?;
}
}
if let Some(ref litellm) = project.litellm_config {
if let Some(ref v) = litellm.api_key {
secure::store_project_secret(&project.id, "litellm-api-key", v)?;
}
}
Ok(())
}
@@ -51,6 +56,10 @@ fn load_secrets_for_project(project: &mut Project) {
bedrock.aws_bearer_token = secure::get_project_secret(&project.id, "aws-bearer-token")
.unwrap_or(None);
}
if let Some(ref mut litellm) = project.litellm_config {
litellm.api_key = secure::get_project_secret(&project.id, "litellm-api-key")
.unwrap_or(None);
}
}
/// Resolve enabled MCP servers and filter to Docker-only ones.
@@ -170,13 +179,29 @@ pub async fn start_project_container(
// Resolve enabled MCP servers for this project
let (enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
// Validate auth mode requirements
if project.auth_mode == AuthMode::Bedrock {
// Validate backend requirements
if project.backend == Backend::Bedrock {
let bedrock = project.bedrock_config.as_ref()
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
.ok_or_else(|| "Bedrock backend selected but no Bedrock configuration found.".to_string())?;
// Region can come from per-project or global
if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() {
return Err("AWS region is required for Bedrock auth mode. Set it per-project or in global AWS settings.".to_string());
return Err("AWS region is required for Bedrock backend. Set it per-project or in global AWS settings.".to_string());
}
}
if project.backend == Backend::Ollama {
let ollama = project.ollama_config.as_ref()
.ok_or_else(|| "Ollama backend selected but no Ollama configuration found.".to_string())?;
if ollama.base_url.is_empty() {
return Err("Ollama base URL is required.".to_string());
}
}
if project.backend == Backend::LiteLlm {
let litellm = project.litellm_config.as_ref()
.ok_or_else(|| "LiteLLM backend selected but no LiteLLM configuration found.".to_string())?;
if litellm.base_url.is_empty() {
return Err("LiteLLM base URL is required.".to_string());
}
}
@@ -202,6 +227,28 @@ pub async fn start_project_container(
// Set up Docker network and MCP containers if needed
let network_name = if !docker_mcp.is_empty() {
// Pull any missing MCP Docker images before starting containers
for server in &docker_mcp {
if let Some(ref image) = server.docker_image {
if !docker::image_exists(image).await.unwrap_or(false) {
emit_progress(
&app_handle,
&project_id,
&format!("Pulling MCP image for '{}'...", server.name),
);
let image_clone = image.clone();
let app_clone = app_handle.clone();
let pid_clone = project_id.clone();
let sname = server.name.clone();
docker::pull_image(&image_clone, move |msg| {
emit_progress(&app_clone, &pid_clone, &format!("[{}] {}", sname, msg));
}).await.map_err(|e| {
format!("Failed to pull MCP image '{}' for '{}': {}", image, server.name, e)
})?;
}
}
}
emit_progress(&app_handle, &project_id, "Setting up MCP network...");
let net = docker::ensure_project_network(&project.id).await?;
emit_progress(&app_handle, &project_id, "Starting MCP containers...");
@@ -386,6 +433,46 @@ pub async fn rebuild_project_container(
start_project_container(project_id, app_handle, state).await
}
/// Reconcile project statuses against actual Docker container state.
/// Called by the frontend after Docker is confirmed available. Projects
/// marked as Running whose containers are no longer running get reset
/// to Stopped.
#[tauri::command]
pub async fn reconcile_project_statuses(
state: State<'_, AppState>,
) -> Result<Vec<Project>, String> {
let projects = state.projects_store.list();
for project in &projects {
if project.status != ProjectStatus::Running && project.status != ProjectStatus::Error {
continue;
}
let is_running = if let Some(ref container_id) = project.container_id {
docker::is_container_running(container_id).await.unwrap_or(false)
} else {
false
};
if is_running {
log::info!(
"Project '{}' ({}) container is still running — keeping Running status",
project.name,
project.id
);
} else {
log::info!(
"Project '{}' ({}) container is not running — setting to Stopped",
project.name,
project.id
);
let _ = state.projects_store.update_status(&project.id, ProjectStatus::Stopped);
}
}
Ok(state.projects_store.list())
}
fn default_docker_socket() -> String {
if cfg!(target_os = "windows") {
"//./pipe/docker_engine".to_string()

View File

@@ -1,11 +1,80 @@
use tauri::{AppHandle, Emitter, State};
use crate::models::{Backend, BedrockAuthMethod, Project};
use crate::AppState;
/// Build the command to run in the container terminal.
///
/// 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`
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
let is_bedrock_profile = project.backend == Backend::Bedrock
&& project
.bedrock_config
.as_ref()
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
.unwrap_or(false);
if !is_bedrock_profile {
return vec![
"claude".to_string(),
"--dangerously-skip-permissions".to_string(),
];
}
// Resolve AWS profile: project-level → global settings → "default"
let profile = project
.bedrock_config
.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,
// then exec's into claude.
let script = format!(
r#"
echo "Validating AWS session for profile '{profile}'..."
if aws sts get-caller-identity --profile '{profile}' >/dev/null 2>&1; then
echo "AWS session valid."
else
echo "AWS session expired or invalid."
# Check if this profile uses SSO (has sso_start_url or sso_session configured)
if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1 || \
aws configure get sso_session --profile '{profile}' >/dev/null 2>&1; then
echo "Starting SSO login..."
echo ""
triple-c-sso-refresh
if [ $? -ne 0 ]; then
echo ""
echo "SSO login failed or was cancelled. Starting Claude anyway..."
echo "You may see authentication errors."
echo ""
fi
else
echo "Profile '{profile}' does not use SSO. Check your AWS credentials."
echo "Starting Claude anyway..."
echo ""
fi
fi
exec claude --dangerously-skip-permissions
"#,
profile = profile
);
vec![
"bash".to_string(),
"-c".to_string(),
script,
]
}
#[tauri::command]
pub async fn open_terminal_session(
project_id: String,
session_id: String,
session_type: Option<String>,
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
@@ -19,10 +88,10 @@ pub async fn open_terminal_session(
.as_ref()
.ok_or_else(|| "Container not running".to_string())?;
let cmd = vec![
"claude".to_string(),
"--dangerously-skip-permissions".to_string(),
];
let cmd = match session_type.as_deref() {
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
_ => build_terminal_cmd(&project, &state),
};
let output_event = format!("terminal-output-{}", session_id);
let exit_event = format!("terminal-exit-{}", session_id);
@@ -69,6 +138,10 @@ pub async fn close_terminal_session(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
// Close audio bridge if it exists
let audio_session_id = format!("audio-{}", session_id);
state.exec_manager.close_session(&audio_session_id).await;
// Close terminal session
state.exec_manager.close_session(&session_id).await;
Ok(())
}
@@ -92,3 +165,53 @@ pub async fn paste_image_to_terminal(
.write_file_to_container(&container_id, &file_name, &image_data)
.await
}
#[tauri::command]
pub async fn start_audio_bridge(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
// Get container_id from the terminal session
let container_id = state.exec_manager.get_container_id(&session_id).await?;
// Create audio bridge exec session with ID "audio-{session_id}"
// The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted
let audio_session_id = format!("audio-{}", session_id);
let cmd = vec![
"bash".to_string(),
"-c".to_string(),
"FIFO=/tmp/triple-c-audio-input; [ -p \"$FIFO\" ] || mkfifo \"$FIFO\"; trap '' PIPE; while true; do cat > \"$FIFO\" 2>/dev/null; sleep 0.1; done".to_string(),
];
state
.exec_manager
.create_session_with_tty(
&container_id,
&audio_session_id,
cmd,
false,
|_data| { /* ignore output from the audio bridge */ },
Box::new(|| { /* no exit handler needed */ }),
)
.await
}
#[tauri::command]
pub async fn send_audio_data(
session_id: String,
data: Vec<u8>,
state: State<'_, AppState>,
) -> Result<(), String> {
let audio_session_id = format!("audio-{}", session_id);
state.exec_manager.send_input(&audio_session_id, data).await
}
#[tauri::command]
pub async fn stop_audio_bridge(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let audio_session_id = format!("audio-{}", session_id);
state.exec_manager.close_session(&audio_session_id).await;
Ok(())
}

View File

@@ -1,8 +1,16 @@
use crate::models::{GiteaRelease, ReleaseAsset, UpdateInfo};
use tauri::State;
use crate::docker;
use crate::models::{container_config, GiteaRelease, ImageUpdateInfo, ReleaseAsset, UpdateInfo};
use crate::AppState;
const RELEASES_URL: &str =
"https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases";
/// Gitea container-registry tag object (v2 manifest).
const REGISTRY_API_BASE: &str =
"https://repo.anhonesthost.net/v2/cybercovellc/triple-c/triple-c-sandbox";
#[tauri::command]
pub fn get_app_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
@@ -26,30 +34,37 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
.map_err(|e| format!("Failed to parse releases: {}", e))?;
let current_version = env!("CARGO_PKG_VERSION");
let is_windows = cfg!(target_os = "windows");
let current_semver = parse_semver(current_version).unwrap_or((0, 0, 0));
// Determine platform suffix for tag filtering
let platform_suffix: &str = if cfg!(target_os = "windows") {
"-win"
} else if cfg!(target_os = "macos") {
"-mac"
} else {
"" // Linux uses bare tags (no suffix)
};
// Filter releases by platform tag suffix
let platform_releases: Vec<&GiteaRelease> = releases
.iter()
.filter(|r| {
if is_windows {
r.tag_name.ends_with("-win")
if platform_suffix.is_empty() {
// Linux: bare tag only (no -win, no -mac)
!r.tag_name.ends_with("-win") && !r.tag_name.ends_with("-mac")
} else {
!r.tag_name.ends_with("-win")
r.tag_name.ends_with(platform_suffix)
}
})
.collect();
// Find the latest release with a higher patch version
// Version format: 0.1.X or v0.1.X (tag may have prefix/suffix)
let current_patch = parse_patch_version(current_version).unwrap_or(0);
let mut best: Option<(&GiteaRelease, u32)> = None;
// Find the latest release with a higher semver version
let mut best: Option<(&GiteaRelease, (u32, u32, u32))> = None;
for release in &platform_releases {
if let Some(patch) = parse_patch_from_tag(&release.tag_name) {
if patch > current_patch {
if best.is_none() || patch > best.unwrap().1 {
best = Some((release, patch));
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
if ver > current_semver {
if best.is_none() || ver > best.unwrap().1 {
best = Some((release, ver));
}
}
}
@@ -84,34 +99,125 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
}
}
/// Parse patch version from a semver string like "0.1.5" -> 5
fn parse_patch_version(version: &str) -> Option<u32> {
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
let clean = version.trim_start_matches('v');
let parts: Vec<&str> = clean.split('.').collect();
if parts.len() >= 3 {
parts[2].parse().ok()
let major = parts[0].parse().ok()?;
let minor = parts[1].parse().ok()?;
let patch = parts[2].parse().ok()?;
Some((major, minor, patch))
} else {
None
}
}
/// Parse patch version from a tag like "v0.1.5", "v0.1.5-win", "0.1.5" -> 5
fn parse_patch_from_tag(tag: &str) -> Option<u32> {
/// Parse semver from a tag like "v0.2.5", "v0.2.5-win", "v0.2.5-mac" -> (0, 2, 5)
fn parse_semver_from_tag(tag: &str) -> Option<(u32, u32, u32)> {
let clean = tag.trim_start_matches('v');
// Remove platform suffix
let clean = clean.strip_suffix("-win").unwrap_or(clean);
parse_patch_version(clean)
let clean = clean.strip_suffix("-win")
.or_else(|| clean.strip_suffix("-mac"))
.unwrap_or(clean);
parse_semver(clean)
}
/// Extract a clean version string from a tag like "v0.1.5-win" -> "0.1.5"
/// Extract a clean version string from a tag like "v0.2.5-win" -> "0.2.5"
fn extract_version_from_tag(tag: &str) -> Option<String> {
let clean = tag.trim_start_matches('v');
let clean = clean.strip_suffix("-win").unwrap_or(clean);
// Validate it looks like a version
let parts: Vec<&str> = clean.split('.').collect();
if parts.len() >= 3 && parts.iter().all(|p| p.parse::<u32>().is_ok()) {
Some(clean.to_string())
} else {
None
let (major, minor, patch) = parse_semver_from_tag(tag)?;
Some(format!("{}.{}.{}", major, minor, patch))
}
/// Check whether a newer container image is available in the registry.
///
/// Compares the local image digest with the remote registry digest using the
/// Docker Registry HTTP API v2. Only applies when the image source is
/// "registry" (the default); for local builds or custom images we cannot
/// meaningfully check for remote updates.
#[tauri::command]
pub async fn check_image_update(
state: State<'_, AppState>,
) -> Result<Option<ImageUpdateInfo>, String> {
let settings = state.settings_store.get();
// Only check for registry images
if settings.image_source != crate::models::app_settings::ImageSource::Registry {
return Ok(None);
}
let image_name =
container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
// 1. Get local image digest via Docker
let local_digest = docker::get_local_image_digest(&image_name).await.ok().flatten();
// 2. Get remote digest from the Gitea container registry (OCI distribution spec)
let remote_digest = fetch_remote_digest("latest").await?;
// No remote digest available — nothing to compare
let remote_digest = match remote_digest {
Some(d) => d,
None => return Ok(None),
};
// If local digest matches remote, no update
if let Some(ref local) = local_digest {
if *local == remote_digest {
return Ok(None);
}
}
// There's a difference (or no local image at all)
Ok(Some(ImageUpdateInfo {
remote_digest,
local_digest,
remote_updated_at: None,
}))
}
/// Fetch the digest of a tag from the Gitea container registry using the
/// OCI / Docker Registry HTTP API v2.
///
/// We issue a HEAD request to /v2/<repo>/manifests/<tag> and read the
/// `Docker-Content-Digest` header that the registry returns.
async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> {
let url = format!("{}/manifests/{}", REGISTRY_API_BASE, tag);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let response = client
.head(&url)
.header(
"Accept",
"application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json",
)
.send()
.await;
match response {
Ok(resp) => {
if !resp.status().is_success() {
log::warn!(
"Registry returned status {} when checking image digest",
resp.status()
);
return Ok(None);
}
// The digest is returned in the Docker-Content-Digest header
if let Some(digest) = resp.headers().get("docker-content-digest") {
if let Ok(val) = digest.to_str() {
return Ok(Some(val.to_string()));
}
}
Ok(None)
}
Err(e) => {
log::warn!("Failed to check registry for image update: {}", e);
Ok(None)
}
}
}

View File

@@ -5,11 +5,10 @@ use bollard::container::{
use bollard::image::{CommitContainerOptions, RemoveImageOptions};
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding};
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use sha2::{Sha256, Digest};
use super::client::get_docker;
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
use crate::models::{Backend, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
@@ -41,6 +40,54 @@ After tasks run, check notifications with `triple-c-scheduler notifications` and
### Timezone
Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#;
const MISSION_CONTROL_GLOBAL_INSTRUCTIONS: &str = r#"## Mission Control
The `/workspace/mission-control/` directory contains **Flight Control** — an AI-first development methodology for structured project management. Use it for all project work.
### How It Works
- **Mission Control is a tool, not a project.** It provides skills and methodology for managing other projects.
- All Flight Control skills are installed as personal skills in `~/.claude/skills/` and are automatically available as `/slash-commands`
- The methodology docs and project registry live in `/workspace/mission-control/`
### When to Use
When working on any project that has a `.flightops/` directory, follow the Flight Control methodology:
1. Read the project's `.flightops/ARTIFACTS.md` to understand artifact storage
2. Read `.flightops/FLIGHT_OPERATIONS.md` for the implementation workflow
3. Use Mission Control skills for planning and execution
### Available Skills
| Skill | When to Use |
|-------|-------------|
| `/init-project` | Setting up a new project for Flight Control |
| `/mission` | Defining new work outcomes (days-to-weeks scope) |
| `/flight` | Creating technical specs from missions (hours-to-days scope) |
| `/leg` | Generating implementation steps from flights (minutes-to-hours scope) |
| `/agentic-workflow` | Executing legs with multi-agent workflow (implement, review, commit) |
| `/flight-debrief` | Post-flight analysis after a flight lands |
| `/mission-debrief` | Post-mission retrospective after completion |
| `/daily-briefing` | Cross-project status report |
### Key Rules
- **Planning skills produce artifacts only** — never modify source code directly
- **Phase gates require human confirmation** — missions before flights, flights before legs
- **Legs are immutable once in-flight** — create new ones instead of modifying
- **`/agentic-workflow` orchestrates implementation** — it spawns separate Developer and Reviewer agents
- **Artifacts live in the target project** — not in mission-control"#;
const MISSION_CONTROL_PROJECT_INSTRUCTIONS: &str = r#"## Flight Operations
This project uses [Flight Control](https://github.com/msieurthenardier/mission-control) for structured development.
**Before any mission/flight/leg work, read these files in order:**
1. `.flightops/README.md` — What the flightops directory contains
2. `.flightops/FLIGHT_OPERATIONS.md` — **The workflow you MUST follow**
3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored
4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)"#;
/// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
/// instructions, appending port mapping docs, and appending scheduler docs.
/// Used by both create_container() and container_needs_recreation() to ensure
@@ -49,8 +96,13 @@ fn build_claude_instructions(
global_instructions: Option<&str>,
project_instructions: Option<&str>,
port_mappings: &[PortMapping],
mission_control_enabled: bool,
) -> Option<String> {
let mut combined = merge_claude_instructions(global_instructions, project_instructions);
let mut combined = merge_claude_instructions(
global_instructions,
project_instructions,
mission_control_enabled,
);
if !port_mappings.is_empty() {
let mut port_lines: Vec<String> = Vec::new();
@@ -117,32 +169,90 @@ fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec<EnvVar> {
}
/// Merge global and per-project Claude instructions into a single string.
/// When mission_control_enabled is true, appends Mission Control global
/// instructions after global and project instructions after project.
fn merge_claude_instructions(
global_instructions: Option<&str>,
project_instructions: Option<&str>,
mission_control_enabled: bool,
) -> Option<String> {
match (global_instructions, project_instructions) {
// Build the global portion (user global + optional MC global)
let global_part = if mission_control_enabled {
match global_instructions {
Some(g) => Some(format!("{}\n\n{}", g, MISSION_CONTROL_GLOBAL_INSTRUCTIONS)),
None => Some(MISSION_CONTROL_GLOBAL_INSTRUCTIONS.to_string()),
}
} else {
global_instructions.map(|g| g.to_string())
};
// Build the project portion (user project + optional MC project)
let project_part = if mission_control_enabled {
match project_instructions {
Some(p) => Some(format!("{}\n\n{}", p, MISSION_CONTROL_PROJECT_INSTRUCTIONS)),
None => Some(MISSION_CONTROL_PROJECT_INSTRUCTIONS.to_string()),
}
} else {
project_instructions.map(|p| p.to_string())
};
match (global_part, project_part) {
(Some(g), Some(p)) => Some(format!("{}\n\n{}", g, p)),
(Some(g), None) => Some(g.to_string()),
(None, Some(p)) => Some(p.to_string()),
(Some(g), None) => Some(g),
(None, Some(p)) => Some(p),
(None, None) => None,
}
}
/// Hash a string with SHA-256 and return the hex digest.
fn sha256_hex(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
format!("{:x}", hasher.finalize())
}
/// Compute a fingerprint for the Bedrock configuration so we can detect changes.
fn compute_bedrock_fingerprint(project: &Project) -> String {
if let Some(ref bedrock) = project.bedrock_config {
let mut hasher = DefaultHasher::new();
format!("{:?}", bedrock.auth_method).hash(&mut hasher);
bedrock.aws_region.hash(&mut hasher);
bedrock.aws_access_key_id.hash(&mut hasher);
bedrock.aws_secret_access_key.hash(&mut hasher);
bedrock.aws_session_token.hash(&mut hasher);
bedrock.aws_profile.hash(&mut hasher);
bedrock.aws_bearer_token.hash(&mut hasher);
bedrock.model_id.hash(&mut hasher);
bedrock.disable_prompt_caching.hash(&mut hasher);
format!("{:x}", hasher.finish())
let parts = vec![
format!("{:?}", bedrock.auth_method),
bedrock.aws_region.clone(),
bedrock.aws_access_key_id.as_deref().unwrap_or("").to_string(),
bedrock.aws_secret_access_key.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_bearer_token.as_deref().unwrap_or("").to_string(),
bedrock.model_id.as_deref().unwrap_or("").to_string(),
format!("{}", bedrock.disable_prompt_caching),
];
sha256_hex(&parts.join("|"))
} else {
String::new()
}
}
/// Compute a fingerprint for the Ollama configuration so we can detect changes.
fn compute_ollama_fingerprint(project: &Project) -> String {
if let Some(ref ollama) = project.ollama_config {
let parts = vec![
ollama.base_url.clone(),
ollama.model_id.as_deref().unwrap_or("").to_string(),
];
sha256_hex(&parts.join("|"))
} else {
String::new()
}
}
/// Compute a fingerprint for the LiteLLM configuration so we can detect changes.
fn compute_litellm_fingerprint(project: &Project) -> String {
if let Some(ref litellm) = project.litellm_config {
let parts = vec![
litellm.base_url.clone(),
litellm.api_key.as_deref().unwrap_or("").to_string(),
litellm.model_id.as_deref().unwrap_or("").to_string(),
];
sha256_hex(&parts.join("|"))
} else {
String::new()
}
@@ -157,9 +267,7 @@ fn compute_paths_fingerprint(paths: &[ProjectPath]) -> String {
.collect();
parts.sort();
let joined = parts.join(",");
let mut hasher = DefaultHasher::new();
joined.hash(&mut hasher);
format!("{:x}", hasher.finish())
sha256_hex(&joined)
}
/// Compute a fingerprint for port mappings so we can detect changes.
@@ -171,9 +279,7 @@ fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
.collect();
parts.sort();
let joined = parts.join(",");
let mut hasher = DefaultHasher::new();
joined.hash(&mut hasher);
format!("{:x}", hasher.finish())
sha256_hex(&joined)
}
/// Build the JSON value for MCP servers config to be injected into ~/.claude.json.
@@ -250,9 +356,7 @@ fn compute_mcp_fingerprint(servers: &[McpServer]) -> String {
return String::new();
}
let json = build_mcp_servers_json(servers);
let mut hasher = DefaultHasher::new();
json.hash(&mut hasher);
format!("{:x}", hasher.finish())
sha256_hex(&json)
}
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
@@ -349,7 +453,7 @@ pub async fn create_container(
}
// Bedrock configuration
if project.auth_mode == AuthMode::Bedrock {
if project.backend == Backend::Bedrock {
if let Some(ref bedrock) = project.bedrock_config {
env_vars.push("CLAUDE_CODE_USE_BEDROCK=1".to_string());
@@ -382,6 +486,7 @@ pub async fn create_container(
if let Some(p) = profile {
env_vars.push(format!("AWS_PROFILE={}", p));
}
env_vars.push("AWS_SSO_AUTH_REFRESH_CMD=triple-c-sso-refresh".to_string());
}
BedrockAuthMethod::BearerToken => {
if let Some(ref token) = bedrock.aws_bearer_token {
@@ -400,6 +505,30 @@ pub async fn create_container(
}
}
// Ollama configuration
if project.backend == Backend::Ollama {
if let Some(ref ollama) = project.ollama_config {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", ollama.base_url));
env_vars.push("ANTHROPIC_AUTH_TOKEN=ollama".to_string());
if let Some(ref model) = ollama.model_id {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
}
}
}
// LiteLLM configuration
if project.backend == Backend::LiteLlm {
if let Some(ref litellm) = project.litellm_config {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", litellm.base_url));
if let Some(ref key) = litellm.api_key {
env_vars.push(format!("ANTHROPIC_AUTH_TOKEN={}", key));
}
if let Some(ref model) = litellm.model_id {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
}
}
}
// 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 reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
@@ -425,11 +554,17 @@ pub async fn create_container(
}
}
// Mission Control env var
if project.mission_control_enabled {
env_vars.push("MISSION_CONTROL_ENABLED=1".to_string());
}
// Claude instructions (global + per-project, plus port mapping info + scheduler docs)
let combined_instructions = build_claude_instructions(
global_claude_instructions,
project.claude_instructions.as_deref(),
&project.port_mappings,
project.mission_control_enabled,
);
if let Some(ref instructions) = combined_instructions {
@@ -489,7 +624,7 @@ pub async fn create_container(
// AWS config mount (read-only)
// Mount if: Bedrock profile auth needs it, OR a global aws_config_path is set
let should_mount_aws = if project.auth_mode == AuthMode::Bedrock {
let should_mount_aws = if project.backend == Backend::Bedrock {
if let Some(ref bedrock) = project.bedrock_config {
bedrock.auth_method == BedrockAuthMethod::Profile
} else {
@@ -507,7 +642,7 @@ pub async fn create_container(
if let Some(ref aws_path) = aws_dir {
if aws_path.exists() {
mounts.push(Mount {
target: Some("/home/claude/.aws".to_string()),
target: Some("/tmp/.host-aws".to_string()),
source: Some(aws_path.to_string_lossy().to_string()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(true),
@@ -559,13 +694,16 @@ pub async fn create_container(
labels.insert("triple-c.managed".to_string(), "true".to_string());
labels.insert("triple-c.project-id".to_string(), project.id.clone());
labels.insert("triple-c.project-name".to_string(), project.name.clone());
labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode));
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.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
labels.insert("triple-c.ollama-fingerprint".to_string(), compute_ollama_fingerprint(project));
labels.insert("triple-c.litellm-fingerprint".to_string(), compute_litellm_fingerprint(project));
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.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.mission-control".to_string(), project.mission_control_enabled.to_string());
let host_config = HostConfig {
mounts: Some(mounts),
@@ -759,11 +897,13 @@ pub async fn container_needs_recreation(
// Code settings stored in the named volume). The change takes effect
// on the next explicit rebuild instead.
// ── Auth mode ────────────────────────────────────────────────────────
let current_auth_mode = format!("{:?}", project.auth_mode);
if let Some(container_auth_mode) = get_label("triple-c.auth-mode") {
if container_auth_mode != current_auth_mode {
log::info!("Auth mode mismatch (container={:?}, project={:?})", container_auth_mode, current_auth_mode);
// ── Backend ──────────────────────────────────────────────────────────
let current_backend = format!("{:?}", project.backend);
// Check new label name, falling back to old "triple-c.auth-mode" for pre-rename containers
let container_backend = get_label("triple-c.backend").or_else(|| get_label("triple-c.auth-mode"));
if let Some(container_backend) = container_backend {
if container_backend != current_backend {
log::info!("Backend mismatch (container={:?}, project={:?})", container_backend, current_backend);
return Ok(true);
}
}
@@ -800,6 +940,22 @@ pub async fn container_needs_recreation(
return Ok(true);
}
// ── Ollama config fingerprint ────────────────────────────────────────
let expected_ollama_fp = compute_ollama_fingerprint(project);
let container_ollama_fp = get_label("triple-c.ollama-fingerprint").unwrap_or_default();
if container_ollama_fp != expected_ollama_fp {
log::info!("Ollama config mismatch");
return Ok(true);
}
// ── LiteLLM config fingerprint ───────────────────────────────────────
let expected_litellm_fp = compute_litellm_fingerprint(project);
let container_litellm_fp = get_label("triple-c.litellm-fingerprint").unwrap_or_default();
if container_litellm_fp != expected_litellm_fp {
log::info!("LiteLLM config mismatch");
return Ok(true);
}
// ── Image ────────────────────────────────────────────────────────────
// The image label is set at creation time; if the user changed the
// configured image we need to recreate. We only compare when the
@@ -884,11 +1040,20 @@ pub async fn container_needs_recreation(
return Ok(true);
}
// ── Mission Control ────────────────────────────────────────────────────
let expected_mc = project.mission_control_enabled.to_string();
let container_mc = get_label("triple-c.mission-control").unwrap_or_else(|| "false".to_string());
if container_mc != expected_mc {
log::info!("Mission Control mismatch (container={:?}, expected={:?})", container_mc, expected_mc);
return Ok(true);
}
// ── Claude instructions ───────────────────────────────────────────────
let expected_instructions = build_claude_instructions(
global_claude_instructions,
project.claude_instructions.as_deref(),
&project.port_mappings,
project.mission_control_enabled,
);
let container_instructions = get_env("CLAUDE_INSTRUCTIONS");
if container_instructions.as_deref() != expected_instructions.as_deref() {
@@ -938,6 +1103,16 @@ pub async fn get_container_info(project: &Project) -> Result<Option<ContainerInf
}
}
/// Check whether a Docker container is currently running.
/// Returns false if the container doesn't exist or Docker is unavailable.
pub async fn is_container_running(container_id: &str) -> Result<bool, String> {
let docker = get_docker()?;
match docker.inspect_container(container_id, None).await {
Ok(info) => Ok(info.state.and_then(|s| s.running).unwrap_or(false)),
Err(_) => Ok(false),
}
}
pub async fn list_sibling_containers() -> Result<Vec<ContainerSummary>, String> {
let docker = get_docker()?;
@@ -970,11 +1145,6 @@ pub fn any_stdio_docker_mcp(servers: &[McpServer]) -> bool {
servers.iter().any(|s| s.is_docker() && s.transport_type == McpTransportType::Stdio)
}
/// Returns true if any MCP server uses Docker.
pub fn any_docker_mcp(servers: &[McpServer]) -> bool {
servers.iter().any(|s| s.is_docker())
}
/// Find an existing MCP container by its expected name.
pub async fn find_mcp_container(server: &McpServer) -> Result<Option<String>, String> {
let docker = get_docker()?;

View File

@@ -22,6 +22,7 @@ impl ExecSession {
.map_err(|e| format!("Failed to send input: {}", e))
}
#[allow(dead_code)]
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
let docker = get_docker()?;
docker
@@ -60,6 +61,22 @@ impl ExecSessionManager {
on_output: F,
on_exit: Box<dyn FnOnce() + Send>,
) -> Result<(), String>
where
F: Fn(Vec<u8>) + Send + 'static,
{
self.create_session_with_tty(container_id, session_id, cmd, true, on_output, on_exit)
.await
}
pub async fn create_session_with_tty<F>(
&self,
container_id: &str,
session_id: &str,
cmd: Vec<String>,
tty: bool,
on_output: F,
on_exit: Box<dyn FnOnce() + Send>,
) -> Result<(), String>
where
F: Fn(Vec<u8>) + Send + 'static,
{
@@ -72,7 +89,7 @@ impl ExecSessionManager {
attach_stdin: Some(true),
attach_stdout: Some(true),
attach_stderr: Some(true),
tty: Some(true),
tty: Some(tty),
cmd: Some(cmd),
user: Some("claude".to_string()),
working_dir: Some("/workspace".to_string()),
@@ -261,3 +278,41 @@ impl ExecSessionManager {
Ok(format!("/tmp/{}", file_name))
}
}
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String, String> {
let docker = get_docker()?;
let exec = docker
.create_exec(
container_id,
CreateExecOptions {
attach_stdout: Some(true),
attach_stderr: Some(true),
cmd: Some(cmd),
user: Some("claude".to_string()),
..Default::default()
},
)
.await
.map_err(|e| format!("Failed to create exec: {}", e))?;
let result = docker
.start_exec(&exec.id, None)
.await
.map_err(|e| format!("Failed to start exec: {}", e))?;
match result {
StartExecResults::Attached { mut output, .. } => {
let mut stdout = String::new();
while let Some(msg) = output.next().await {
match msg {
Ok(data) => stdout.push_str(&String::from_utf8_lossy(&data.into_bytes())),
Err(e) => return Err(format!("Exec output error: {}", e)),
}
}
Ok(stdout)
}
StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
}
}

View File

@@ -31,6 +31,38 @@ pub async fn image_exists(image_name: &str) -> Result<bool, String> {
Ok(!images.is_empty())
}
/// Returns the first repo digest (e.g. "sha256:abc...") for the given image,
/// or None if the image doesn't exist locally or has no repo digests.
pub async fn get_local_image_digest(image_name: &str) -> Result<Option<String>, String> {
let docker = get_docker()?;
let filters: HashMap<String, Vec<String>> = HashMap::from([(
"reference".to_string(),
vec![image_name.to_string()],
)]);
let images: Vec<ImageSummary> = docker
.list_images(Some(ListImagesOptions {
filters,
..Default::default()
}))
.await
.map_err(|e| format!("Failed to list images: {}", e))?;
if let Some(img) = images.first() {
// RepoDigests contains entries like "registry/repo@sha256:abc..."
if let Some(digest_str) = img.repo_digests.first() {
// Extract the sha256:... part after '@'
if let Some(pos) = digest_str.find('@') {
return Ok(Some(digest_str[pos + 1..].to_string()));
}
return Ok(Some(digest_str.clone()));
}
}
Ok(None)
}
pub async fn pull_image<F>(image_name: &str, on_progress: F) -> Result<(), String>
where
F: Fn(String) + Send + 'static,

View File

@@ -4,8 +4,13 @@ pub mod image;
pub mod exec;
pub mod network;
#[allow(unused_imports)]
pub use client::*;
#[allow(unused_imports)]
pub use container::*;
#[allow(unused_imports)]
pub use image::*;
#[allow(unused_imports)]
pub use exec::*;
#[allow(unused_imports)]
pub use network::*;

View File

@@ -48,6 +48,7 @@ pub async fn ensure_project_network(project_id: &str) -> Result<String, String>
}
/// Connect a container to the project network.
#[allow(dead_code)]
pub async fn connect_container_to_network(
container_id: &str,
network_name: &str,

View File

@@ -88,6 +88,7 @@ pub fn run() {
commands::project_commands::start_project_container,
commands::project_commands::stop_project_container,
commands::project_commands::rebuild_project_container,
commands::project_commands::reconcile_project_statuses,
// Settings
commands::settings_commands::get_settings,
commands::settings_commands::update_settings,
@@ -101,14 +102,24 @@ pub fn run() {
commands::terminal_commands::terminal_resize,
commands::terminal_commands::close_terminal_session,
commands::terminal_commands::paste_image_to_terminal,
commands::terminal_commands::start_audio_bridge,
commands::terminal_commands::send_audio_data,
commands::terminal_commands::stop_audio_bridge,
// Files
commands::file_commands::list_container_files,
commands::file_commands::download_container_file,
commands::file_commands::upload_file_to_container,
// MCP
commands::mcp_commands::list_mcp_servers,
commands::mcp_commands::add_mcp_server,
commands::mcp_commands::update_mcp_server,
commands::mcp_commands::remove_mcp_server,
// AWS
commands::aws_commands::aws_sso_refresh,
// Updates
commands::update_commands::get_app_version,
commands::update_commands::check_for_updates,
commands::update_commands::check_image_update,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -70,6 +70,10 @@ pub struct AppSettings {
pub dismissed_update_version: Option<String>,
#[serde(default)]
pub timezone: Option<String>,
#[serde(default)]
pub default_microphone: Option<String>,
#[serde(default)]
pub dismissed_image_digest: Option<String>,
}
impl Default for AppSettings {
@@ -87,6 +91,8 @@ impl Default for AppSettings {
auto_check_updates: true,
dismissed_update_version: None,
timezone: None,
default_microphone: None,
dismissed_image_digest: None,
}
}
}

View File

@@ -31,11 +31,16 @@ pub struct Project {
pub paths: Vec<ProjectPath>,
pub container_id: Option<String>,
pub status: ProjectStatus,
pub auth_mode: AuthMode,
#[serde(alias = "auth_mode")]
pub backend: Backend,
pub bedrock_config: Option<BedrockConfig>,
pub ollama_config: Option<OllamaConfig>,
pub litellm_config: Option<LiteLlmConfig>,
pub allow_docker_access: bool,
#[serde(default)]
pub mission_control_enabled: bool,
pub ssh_key_path: Option<String>,
#[serde(skip_serializing)]
#[serde(skip_serializing, default)]
pub git_token: Option<String>,
pub git_user_name: Option<String>,
pub git_user_email: Option<String>,
@@ -61,20 +66,24 @@ pub enum ProjectStatus {
Error,
}
/// How the project authenticates with Claude.
/// - `Anthropic`: User runs `claude login` inside the container (OAuth via Anthropic Console,
/// persisted in the config volume)
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
/// Which AI model backend/provider the project uses.
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
/// - `Ollama`: Local or remote Ollama server
/// - `LiteLlm`: LiteLLM proxy gateway for 100+ model providers
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AuthMode {
pub enum Backend {
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
#[serde(alias = "login", alias = "api_key")]
Anthropic,
Bedrock,
Ollama,
#[serde(alias = "litellm")]
LiteLlm,
}
impl Default for AuthMode {
impl Default for Backend {
fn default() -> Self {
Self::Anthropic
}
@@ -100,19 +109,42 @@ impl Default for BedrockAuthMethod {
pub struct BedrockConfig {
pub auth_method: BedrockAuthMethod,
pub aws_region: String,
#[serde(skip_serializing)]
#[serde(skip_serializing, default)]
pub aws_access_key_id: Option<String>,
#[serde(skip_serializing)]
#[serde(skip_serializing, default)]
pub aws_secret_access_key: Option<String>,
#[serde(skip_serializing)]
#[serde(skip_serializing, default)]
pub aws_session_token: Option<String>,
pub aws_profile: Option<String>,
#[serde(skip_serializing)]
#[serde(skip_serializing, default)]
pub aws_bearer_token: Option<String>,
pub model_id: Option<String>,
pub disable_prompt_caching: bool,
}
/// Ollama configuration for a project.
/// Ollama exposes an Anthropic-compatible API endpoint.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaConfig {
/// The base URL of the Ollama server (e.g., "http://host.docker.internal:11434" or "http://192.168.1.100:11434")
pub base_url: String,
/// Optional model override (e.g., "qwen3.5:27b")
pub model_id: Option<String>,
}
/// LiteLLM gateway configuration for a project.
/// LiteLLM translates Anthropic API calls to 100+ model providers.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiteLlmConfig {
/// The base URL of the LiteLLM proxy (e.g., "http://host.docker.internal:4000" or "https://litellm.example.com")
pub base_url: String,
/// API key for the LiteLLM proxy
#[serde(skip_serializing, default)]
pub api_key: Option<String>,
/// Optional model override
pub model_id: Option<String>,
}
impl Project {
pub fn new(name: String, paths: Vec<ProjectPath>) -> Self {
let now = chrono::Utc::now().to_rfc3339();
@@ -122,9 +154,12 @@ impl Project {
paths,
container_id: None,
status: ProjectStatus::Stopped,
auth_mode: AuthMode::default(),
backend: Backend::default(),
bedrock_config: None,
ollama_config: None,
litellm_config: None,
allow_docker_access: false,
mission_control_enabled: false,
ssh_key_path: None,
git_token: None,
git_user_name: None,

View File

@@ -35,3 +35,14 @@ pub struct GiteaAsset {
pub browser_download_url: String,
pub size: u64,
}
/// Info returned to the frontend about an available container image update.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageUpdateInfo {
/// The remote digest (e.g. sha256:abc...)
pub remote_digest: String,
/// The local digest, if available
pub local_digest: Option<String>,
/// When the remote image was last updated (if known)
pub remote_updated_at: Option<String>,
}

View File

@@ -3,7 +3,11 @@ pub mod secure;
pub mod settings_store;
pub mod mcp_store;
#[allow(unused_imports)]
pub use projects_store::*;
#[allow(unused_imports)]
pub use secure::*;
#[allow(unused_imports)]
pub use settings_store::*;
#[allow(unused_imports)]
pub use mcp_store::*;

View File

@@ -72,6 +72,8 @@ impl ProjectsStore {
// Reconcile stale transient statuses: on a cold app start no Docker
// operations can be in flight, so Starting/Stopping are always stale.
// Running/Error are left as-is and reconciled against Docker later
// via the reconcile_project_statuses command.
let mut projects = projects;
let mut needs_save = needs_save;
for p in projects.iter_mut() {

View File

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

View File

@@ -10,15 +10,16 @@ import { useProjects } from "./hooks/useProjects";
import { useMcpServers } from "./hooks/useMcpServers";
import { useUpdates } from "./hooks/useUpdates";
import { useAppState } from "./store/appState";
import { reconcileProjectStatuses } from "./lib/tauri-commands";
export default function App() {
const { checkDocker, checkImage, startDockerPolling } = useDocker();
const { loadSettings } = useSettings();
const { refresh } = useProjects();
const { refresh: refreshMcp } = useMcpServers();
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
const { sessions, activeSessionId } = useAppState(
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId }))
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
const { sessions, activeSessionId, setProjects } = useAppState(
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects }))
);
// Initialize on mount
@@ -28,6 +29,14 @@ export default function App() {
checkDocker().then((available) => {
if (available) {
checkImage();
// Reconcile project statuses against actual Docker container state,
// then refresh the project list so the UI reflects reality.
reconcileProjectStatuses().then((projects) => {
setProjects(projects);
}).catch(() => {
// If reconciliation fails (e.g. Docker hiccup), just load from store
refresh();
});
} else {
stopPolling = startDockerPolling();
}
@@ -37,7 +46,10 @@ export default function App() {
// Update detection
loadVersion();
const updateTimer = setTimeout(() => checkForUpdates(), 3000);
const updateTimer = setTimeout(() => {
checkForUpdates();
checkImageUpdate();
}, 3000);
const cleanup = startPeriodicCheck();
return () => {
clearTimeout(updateTimer);

View File

@@ -0,0 +1,775 @@
import { useEffect, useRef, useCallback } from "react";
interface Props {
onClose: () => void;
}
const HELP_MARKDOWN = `# How to Use Triple-C
Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code inside isolated Docker containers. Each project gets its own sandboxed environment with bind-mounted directories, so Claude only has access to the files you explicitly provide.
---
## Prerequisites
### Docker
Triple-C requires a running Docker daemon. Install one of the following:
| Platform | Option | Link |
|----------|--------|------|
| **Windows** | Docker Desktop | https://docs.docker.com/desktop/install/windows-install/ |
| **macOS** | Docker Desktop | https://docs.docker.com/desktop/install/mac-install/ |
| **Linux** | Docker Engine | https://docs.docker.com/engine/install/ |
| **Linux** | Docker Desktop (alternative) | https://docs.docker.com/desktop/install/linux/ |
After installation, verify Docker is running:
\`\`\`bash
docker info
\`\`\`
> **Windows note:** Docker Desktop must be running before launching Triple-C. The app communicates with Docker through the named pipe at \`//./pipe/docker_engine\`.
> **Linux note:** Your user must have permission to access the Docker socket (\`/var/run/docker.sock\`). Either add your user to the \`docker\` group (\`sudo usermod -aG docker $USER\`, then log out and back in) or run Docker in rootless mode.
### Claude Code Account
You need access to Claude Code through one of:
- **Anthropic account** — Sign up at https://claude.ai and use \`claude login\` (OAuth) inside the terminal
- **AWS Bedrock** — An AWS account with Bedrock access and Claude models enabled
- **Ollama** — A local or remote Ollama server running an Anthropic-compatible model (best-effort support)
- **LiteLLM** — A LiteLLM proxy gateway providing access to 100+ model providers (best-effort support)
---
## First Launch
### 1. Get the Container Image
When you first open Triple-C, go to the **Settings** tab in the sidebar. Under **Docker**, you'll see:
- **Docker Status** — Should show "Connected" (green). If it shows "Not Available", make sure Docker is running.
- **Image Status** — Will show "Not Found" on first launch.
Choose an **Image Source**:
| Source | Description | When to Use |
|--------|-------------|-------------|
| **Registry** | Pulls the pre-built image from \`repo.anhonesthost.net\` | Fastest setup — recommended for most users |
| **Local Build** | Builds the image locally from the embedded Dockerfile | If you can't reach the registry, or want a custom build |
| **Custom** | Use any Docker image you specify | Advanced — bring your own sandbox image |
Click **Pull Image** (for Registry/Custom) or **Build Image** (for Local Build). A progress log will stream below the button. When complete, the status changes to "Ready" (green).
### 2. Create Your First Project
Switch to the **Projects** tab in the sidebar and click the **+** button.
1. **Project Name** — Give it a meaningful name (e.g., "my-web-app").
2. **Folders** — Click **Browse** to select a directory on your host machine. This directory will be mounted into the container at \`/workspace/<folder-name>\`. You can add multiple folders with the **+** button at the bottom of the folder list.
3. Click **Add Project**.
### 3. Start the Container
Select your project in the sidebar and click **Start**. A progress modal appears showing real-time status as the container starts. The status dot changes from gray (stopped) to orange (starting) to green (running). The modal auto-closes on success.
### 4. Open a Terminal
Click the **Terminal** button to open an interactive terminal session. A new tab appears in the top bar and an xterm.js terminal loads in the main area.
Claude Code launches automatically with \`--dangerously-skip-permissions\` inside the sandboxed container.
### 5. Authenticate
**Anthropic (OAuth) — default:**
1. Type \`claude login\` or \`/login\` in the terminal.
2. Claude prints an OAuth URL. Triple-C detects long URLs and shows a clickable toast at the top of the terminal — click **Open** to open it in your browser.
3. Complete the login in your browser. The token is saved and persists across container stops and resets.
**AWS Bedrock:**
1. Stop the container first (settings can only be changed while stopped).
2. In the project card, switch the backend to **Bedrock**.
3. Expand the **Config** panel and fill in your AWS credentials (see AWS Bedrock Configuration below).
4. Start the container again.
**Ollama:**
1. Stop the container first (settings can only be changed while stopped).
2. In the project card, switch the backend to **Ollama**.
3. Expand the **Config** panel and set the base URL of your Ollama server (defaults to \`http://host.docker.internal:11434\` for a local instance). Optionally set a model ID.
4. Start the container again.
**LiteLLM:**
1. Stop the container first (settings can only be changed while stopped).
2. In the project card, switch the backend to **LiteLLM**.
3. Expand the **Config** panel and set the base URL of your LiteLLM proxy (defaults to \`http://host.docker.internal:4000\`). Optionally set an API key and model ID.
4. Start the container again.
---
## The Interface
\`\`\`
┌─────────────────────────────────────────────────────┐
│ TopBar [ Terminal Tabs ] Docker ● Image ●│
├────────────┬────────────────────────────────────────┤
│ Sidebar │ │
│ │ Terminal View │
│ Projects │ (xterm.js) │
│ MCP │ │
│ Settings │ │
├────────────┴────────────────────────────────────────┤
│ StatusBar X projects · X running · X terminals │
└─────────────────────────────────────────────────────┘
\`\`\`
- **TopBar** — Terminal tabs for switching between sessions. Bash shell tabs show a "(bash)" suffix. Status dots on the right show Docker connection (green = connected) and image availability (green = ready).
- **Sidebar** — Toggle between the **Projects** list, **MCP** server configuration, and **Settings** panel.
- **Terminal View** — Interactive terminal powered by xterm.js with WebGL rendering. Includes a **Jump to Current** button that appears when you scroll up, so you can quickly return to the latest output.
- **StatusBar** — Counts of total projects, running containers, and open terminal sessions.
---
## Project Management
### Project Status
Each project shows a colored status dot:
| Color | Status | Meaning |
|-------|--------|---------|
| Gray | Stopped | Container is not running |
| Orange | Starting / Stopping | Container is transitioning |
| Green | Running | Container is active, ready for terminals |
| Red | Error | Something went wrong (check error message) |
### Project Actions
Select a project in the sidebar to see its action buttons:
| Button | When Available | What It Does |
|--------|---------------|--------------|
| **Start** | Stopped | Creates (if needed) and starts the container |
| **Stop** | Running | Stops the container but preserves its state |
| **Terminal** | Running | Opens a new Claude Code terminal session |
| **Shell** | Running | Opens a bash login shell in the container (no Claude Code) |
| **Files** | Running | Opens the file manager to browse, download, and upload files |
| **Reset** | Stopped | Destroys and recreates the container from scratch |
| **Config** | Always | Toggles the configuration panel |
| **Remove** | Stopped | Deletes the project and its container (with confirmation) |
### Renaming a Project
Double-click the project name in the sidebar to rename it inline. Press **Enter** to confirm or **Escape** to cancel.
### Container Lifecycle
Containers use a **stop/start** model. When you stop a container, everything inside it is preserved — installed packages, modified files, downloaded tools. Starting it again resumes where you left off.
**Reset** removes the container and creates a fresh one. However, your Claude Code configuration (including OAuth tokens from \`claude login\`) is stored in a separate Docker volume and survives resets.
Only **Remove** deletes everything, including the config volume and any stored credentials.
### Container Progress Feedback
When starting, stopping, or resetting a container, a progress modal shows real-time status messages (e.g., "Setting up MCP network...", "Starting MCP containers...", "Creating container..."). If an error occurs, the modal displays the error with a **Close** button. A **Force Stop** option is available if the operation stalls. The modal auto-closes on success.
---
## Project Configuration
Click **Config** on a selected project to expand the configuration panel. Settings can only be changed when the container is **stopped** (an orange warning box appears if the container is running).
### Mounted Folders
Each project mounts one or more host directories into the container. The mount appears at \`/workspace/<mount-name>\` inside the container.
- Click **Browse** ("...") to change the host path
- Edit the mount name to control where it appears inside \`/workspace/\`
- Click **+** to add more folders, or **x** to remove one
- Mount names must be unique and use only letters, numbers, dashes, underscores, and dots
### SSH Keys
Specify the path to your SSH key directory (typically \`~/.ssh\`). Keys are mounted read-only and copied into the container with correct permissions. This enables \`git clone\` via SSH inside the container.
### Git Configuration
- **Git Name / Email** — Sets \`git config user.name\` and \`user.email\` inside the container.
- **Git HTTPS Token** — A personal access token (e.g., from GitHub) for HTTPS git operations. Stored securely in your OS keychain — never written to disk in plaintext.
### Allow Container Spawning
When enabled, the host Docker socket is mounted into the container so Claude Code can create sibling containers (e.g., for running databases, test environments). This is **off by default** for security.
> Toggling this requires stopping and restarting the container to take effect.
### Mission Control
Toggle **Mission Control** to integrate Flight Control — an AI-first development methodology — into the project. When enabled:
- The Flight Control repository is automatically cloned into the container
- Flight Control skills are installed to Claude Code's skill directory (\`~/.claude/skills/\`)
- Project instructions are appended with Flight Control workflow guidance
- The repository is symlinked at \`/workspace/mission-control\`
Available skills include \`/mission\`, \`/flight\`, \`/leg\`, \`/agentic-workflow\`, \`/flight-debrief\`, \`/mission-debrief\`, \`/daily-briefing\`, and \`/init-project\`.
> This setting can only be changed when the container is stopped. Toggling it triggers a container recreation on the next start.
### Environment Variables
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.
### Port Mappings
Click **Edit** to map host ports to container ports. This is useful when Claude Code starts a web server or other service inside the container and you want to access it from your host browser.
Each mapping specifies:
- **Host Port** — The port on your machine (1-65535)
- **Container Port** — The port inside the container (1-65535)
- **Protocol** — TCP (default) or UDP
### Claude 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.
---
## MCP Servers (Beta)
Triple-C supports Model Context Protocol (MCP) servers, which extend Claude Code with access to external tools and data sources. MCP servers are configured in a **global library** and **enabled per-project**.
### How It Works
There are two dimensions to MCP server configuration:
| | **Manual** (no Docker image) | **Docker** (Docker image specified) |
|---|---|---|
| **Stdio** | Command runs inside the project container | Command runs in a separate MCP container via \`docker exec\` |
| **HTTP** | Connects to a URL you provide | Runs in a separate container, reached by hostname on a shared Docker network |
**Docker images are pulled automatically** if not already present when the project starts.
### Accessing MCP Configuration
Click the **MCP** tab in the sidebar to open the MCP server library. This is where you define all available MCP servers.
### Adding an MCP Server
1. Type a name in the input field and click **Add**.
2. Expand the server card and configure it.
The key decision is whether to set a **Docker Image**:
- **With Docker image** — The MCP server runs in its own isolated container. Best for servers that need specific dependencies or system-level packages.
- **Without Docker image** (manual) — The command runs directly inside your project container. Best for lightweight npx-based servers that just need Node.js.
Then choose the **Transport Type**:
- **Stdio** — The MCP server communicates over stdin/stdout. This is the most common type.
- **HTTP** — The MCP server exposes an HTTP endpoint (streamable HTTP transport).
### Configuration Examples
#### Example 1: Filesystem Server (Stdio, Manual)
A simple npx-based server that runs inside the project container. No Docker image needed since Node.js is already installed.
| Field | Value |
|-------|-------|
| **Docker Image** | *(empty)* |
| **Transport** | Stdio |
| **Command** | \`npx\` |
| **Arguments** | \`-y @modelcontextprotocol/server-filesystem /workspace\` |
#### Example 2: GitHub Server (Stdio, Manual)
Another npx-based server, with an environment variable for authentication.
| Field | Value |
|-------|-------|
| **Docker Image** | *(empty)* |
| **Transport** | Stdio |
| **Command** | \`npx\` |
| **Arguments** | \`-y @modelcontextprotocol/server-github\` |
| **Environment Variables** | \`GITHUB_PERSONAL_ACCESS_TOKEN\` = \`ghp_your_token\` |
#### Example 3: Custom MCP Server (HTTP, Docker)
An MCP server packaged as a Docker image that exposes an HTTP endpoint.
| Field | Value |
|-------|-------|
| **Docker Image** | \`myregistry/my-mcp-server:latest\` |
| **Transport** | HTTP |
| **Container Port** | \`8080\` |
| **Environment Variables** | \`API_KEY\` = \`your_key\` |
#### Example 4: Database Server (Stdio, Docker)
An MCP server that needs its own runtime environment, communicating over stdio.
| Field | Value |
|-------|-------|
| **Docker Image** | \`mcp/postgres-server:latest\` |
| **Transport** | Stdio |
| **Command** | \`node\` |
| **Arguments** | \`dist/index.js\` |
| **Environment Variables** | \`DATABASE_URL\` = \`postgresql://user:pass@host:5432/db\` |
### Enabling MCP Servers Per-Project
In a project's configuration panel (click **Config**), the **MCP Servers** section shows checkboxes for all globally defined servers. Toggle each server on or off for that project. Changes take effect on the next container start.
### How Docker-Based MCP Works
When a project with Docker-based MCP servers starts:
1. Missing Docker images are **automatically pulled** (progress shown in the progress modal)
2. A dedicated **bridge network** is created for the project (\`triple-c-net-{projectId}\`)
3. Each enabled Docker MCP server gets its own container on that network
4. The main project container is connected to the same network
5. MCP server configuration is written to \`~/.claude.json\` inside the container
**Networking**: Docker-based MCP containers are reached by their container name as a hostname (e.g., \`triple-c-mcp-{serverId}\`), not by \`localhost\`. Docker DNS resolves these names automatically on the shared bridge network.
**Stdio + Docker**: The project container uses \`docker exec\` to communicate with the MCP container over stdin/stdout. This automatically enables Docker socket access on the project container.
**HTTP + Docker**: The project container connects to the MCP container's HTTP endpoint using the container hostname and port (e.g., \`http://triple-c-mcp-{serverId}:3000/mcp\`).
**Manual (no Docker image)**: Stdio commands run directly inside the project container. HTTP URLs connect to wherever you point them (could be an external service or something running on the host).
### Configuration Change Detection
MCP server configuration is tracked via SHA-256 fingerprints stored as Docker labels. If you add, remove, or modify MCP servers for a project, the container is automatically recreated on the next start to apply the new configuration. The container filesystem is snapshotted first, so installed packages are preserved.
---
## AWS Bedrock Configuration
To use Claude via AWS Bedrock instead of Anthropic's API, switch the backend to **Bedrock** on the project card.
### Authentication Methods
| Method | Fields | Use Case |
|--------|--------|----------|
| **Keys** | Access Key ID, Secret Access Key, Session Token (optional) | Direct credentials — simplest setup |
| **Profile** | AWS Profile name | Uses \`~/.aws/config\` and \`~/.aws/credentials\` on the host |
| **Token** | Bearer Token | Temporary bearer token authentication |
### Additional Bedrock Settings
- **AWS Region** — Required. The region where your Bedrock models are deployed (e.g., \`us-east-1\`).
- **Model ID** — Optional. Override the default Claude model (e.g., \`anthropic.claude-sonnet-4-20250514-v1:0\`).
### Global AWS Defaults
In **Settings > AWS Configuration**, you can set defaults that apply to all Bedrock projects:
- **AWS Config Path** — Path to your \`~/.aws\` directory. Click **Detect** to auto-find it.
- **Default Profile** — Select from profiles found in your AWS config.
- **Default Region** — Fallback region for projects that don't specify one.
Per-project settings always override these global defaults.
---
## Ollama Configuration
To use Claude Code with a local or remote Ollama server, switch the backend to **Ollama** on the project card.
### Settings
- **Base URL** — The URL of your Ollama server. Defaults to \`http://host.docker.internal:11434\`, which reaches a locally running Ollama instance from inside the container. For a remote server, use its IP or hostname (e.g., \`http://192.168.1.100:11434\`).
- **Model ID** — Optional. Override the model to use (e.g., \`qwen3.5:27b\`).
### How It Works
Triple-C sets \`ANTHROPIC_BASE_URL\` to point Claude Code at your Ollama server instead of Anthropic's API. The \`ANTHROPIC_AUTH_TOKEN\` is set to \`ollama\` (required by Claude Code but not used for actual authentication).
> **Note:** Ollama support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models.
---
## LiteLLM Configuration
To use Claude Code through a LiteLLM proxy gateway, switch the backend to **LiteLLM** on the project card. LiteLLM supports 100+ model providers (OpenAI, Gemini, Anthropic, and more) through a single proxy.
### Settings
- **Base URL** — The URL of your LiteLLM proxy. Defaults to \`http://host.docker.internal:4000\` for a locally running proxy.
- **API Key** — Optional. The API key for your LiteLLM proxy, if authentication is required. Stored securely in your OS keychain.
- **Model ID** — Optional. Override the model to use.
### How It Works
Triple-C sets \`ANTHROPIC_BASE_URL\` to point Claude Code at your LiteLLM proxy. If an API key is provided, it is set as \`ANTHROPIC_AUTH_TOKEN\`.
> **Note:** LiteLLM support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected when routing to non-Anthropic models through the proxy.
---
## Settings
Access global settings via the **Settings** tab in the sidebar.
### Docker Settings
- **Docker Status** — Connection status to the Docker daemon.
- **Image Source** — Where to get the sandbox container image (Registry, Local Build, or Custom).
- **Pull / Build Image** — Download or build the image. Progress streams in real time.
- **Refresh** — Re-check Docker and image status.
### Container Timezone
Set the timezone for all containers (IANA format, e.g., \`America/New_York\`, \`Europe/London\`, \`UTC\`). Auto-detected from your host on first launch. This affects scheduled task timing inside containers.
### Global Claude Instructions
Instructions applied to **all** projects. Written to \`~/.claude/CLAUDE.md\` in every container, before any per-project instructions.
### Global Environment Variables
Environment variables applied to **all** project containers. Per-project variables with the same key take precedence.
### Updates
- **Current Version** — The installed version of Triple-C.
- **Auto-check** — Toggle automatic update checks (every 24 hours).
- **Check now** — Manually check for updates.
When an update is available, a pulsing **Update** button appears in the top bar. Click it to see release notes and download links.
---
## Terminal Features
### 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.
### Bash Shell Sessions
In addition to Claude Code terminals, you can open a plain **bash login shell** in any running container by clicking the **Shell** button. This is useful for manual inspection, package installation, debugging, or running commands that don't need Claude Code.
### URL Detection
When Claude Code prints a long URL (e.g., during \`claude login\`), Triple-C detects it and shows a toast notification at the top of the terminal with an **Open** button. Clicking it opens the URL in your default browser. The toast auto-dismisses after 30 seconds.
Shorter URLs in terminal output are also clickable directly.
### Clipboard Support (OSC 52)
Programs inside the container can copy text to your host clipboard. When a container program uses \`xclip\`, \`xsel\`, or \`pbcopy\`, the text is transparently forwarded to your host clipboard via OSC 52 escape sequences. No additional configuration is required — this works out of the box.
### Image Paste
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container as \`/tmp/clipboard_<timestamp>.png\` and the file path is injected into the terminal input so Claude Code can reference it. A toast notification confirms the upload.
### Jump to Current
When you scroll up in the terminal to review previous output, a **Jump to Current** button appears in the bottom-right corner. Click it to scroll back to the latest output.
### File Manager
Click the **Files** button on a running project to open the file manager modal. You can:
- **Browse** the container filesystem starting from \`/workspace\`, with breadcrumb navigation
- **Download** any file to your host machine via the download button on each file entry
- **Upload** files from your host into the current container directory
- **Refresh** the directory listing at any time
The file manager shows file names, sizes, and modification dates.
### Terminal Rendering
The terminal uses WebGL for hardware-accelerated rendering of the active tab. Inactive tabs fall back to canvas rendering to conserve GPU resources. The terminal automatically resizes when you resize the window.
---
## Scheduled Tasks (Inside the Container)
Once inside a running container terminal, you can set up recurring or one-time tasks using \`triple-c-scheduler\`. Tasks run as separate Claude Code sessions.
### Create a Recurring Task
\`\`\`bash
triple-c-scheduler add --name "daily-review" --schedule "0 9 * * *" --prompt "Review open issues and summarize"
\`\`\`
### Create a One-Time Task
\`\`\`bash
triple-c-scheduler add --name "migrate-db" --at "2026-03-05 14:00" --prompt "Run database migrations"
\`\`\`
One-time tasks automatically remove themselves after execution.
### Manage Tasks
\`\`\`bash
triple-c-scheduler list # List all tasks
triple-c-scheduler enable --id abc123 # Enable a task
triple-c-scheduler disable --id abc123 # Disable a task
triple-c-scheduler remove --id abc123 # Delete a task
triple-c-scheduler run --id abc123 # Trigger a task immediately
triple-c-scheduler logs --id abc123 # View logs for a task
triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks)
triple-c-scheduler notifications # View completion notifications
triple-c-scheduler notifications --clear # Clear notifications
\`\`\`
### Cron Schedule Format
Standard 5-field cron: \`minute hour day-of-month month day-of-week\`
| Example | Meaning |
|---------|---------|
| \`*/30 * * * *\` | Every 30 minutes |
| \`0 9 * * 1-5\` | 9:00 AM on weekdays |
| \`0 */2 * * *\` | Every 2 hours |
| \`0 0 1 * *\` | Midnight on the 1st of each month |
### Working Directory
By default, tasks run in \`/workspace\`. Use \`--working-dir\` to specify a different directory:
\`\`\`bash
triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run tests" --working-dir /workspace/my-project
\`\`\`
---
## What's Inside the Container
The sandbox container (Ubuntu 24.04) comes pre-installed with:
| Tool | Version | Purpose |
|------|---------|---------|
| Claude Code | Latest | AI coding assistant (the tool being sandboxed) |
| Node.js | 22 LTS | JavaScript/TypeScript development |
| pnpm | Latest | Fast Node.js package manager |
| Python | 3.12 | Python development |
| uv | Latest | Fast Python package manager |
| ruff | Latest | Python linter/formatter |
| Rust | Stable | Rust development (via rustup) |
| Docker CLI | Latest | Container management (when spawning is enabled) |
| git | Latest | Version control |
| GitHub CLI (gh) | Latest | GitHub integration |
| AWS CLI | v2 | AWS services and Bedrock |
| ripgrep | Latest | Fast code search |
| build-essential | — | C/C++ compiler toolchain |
| openssh-client | — | SSH for git and remote access |
The container also includes **clipboard shims** (\`xclip\`, \`xsel\`, \`pbcopy\`) that forward copy operations to the host via OSC 52, and an **audio shim** (\`rec\`, \`arecord\`) for future voice mode support.
You can install additional tools at runtime with \`sudo apt install\`, \`pip install\`, \`npm install -g\`, etc. Installed packages persist across container stops (but not across resets).
---
## Troubleshooting
### Docker is "Not Available"
- **Is Docker running?** Start Docker Desktop or the Docker daemon (\`sudo systemctl start docker\`).
- **Permissions?** On Linux, ensure your user is in the \`docker\` group or the socket is accessible.
- **Custom socket path?** If your Docker socket is not at the default location, set it in Settings. The app expects \`/var/run/docker.sock\` on Linux/macOS or \`//./pipe/docker_engine\` on Windows.
### Image is "Not Found"
- Click **Pull Image** or **Build Image** in Settings > Docker.
- If pulling fails, check your network connection and whether you can reach the registry.
- Try switching to **Local Build** as an alternative.
### Container Won't Start
- Check that the Docker image is "Ready" in Settings.
- Verify that the mounted folder paths exist on your host.
- Look at the error message displayed in the progress modal.
### OAuth Login URL Not Opening
- Triple-C detects long URLs printed by \`claude login\` and shows a toast with an **Open** button.
- If the toast doesn't appear, try scrolling up in the terminal — the URL may have already been printed.
- You can also manually copy the URL from the terminal output and paste it into your browser.
### File Permission Issues
- Triple-C automatically remaps the container user's UID/GID to match your host user, so files created inside the container should have the correct ownership on your host.
- If you see permission errors, try resetting the container (stop, then click **Reset**).
### Settings Won't Save
- Most project settings can only be changed when the container is **stopped**. Stop the container first, make your changes, then start it again.
- Some changes (like toggling Docker access, Mission Control, or changing mounted folders) trigger an automatic container recreation on the next start.
### MCP Containers Not Starting
- Ensure the Docker image for the MCP server exists (pull it first if needed).
- Check that Docker socket access is available (stdio + Docker MCP servers auto-enable this).
- Try resetting the project container to force a clean recreation.`;
/** Simple markdown-to-HTML converter for the help content. */
function renderMarkdown(md: string): string {
let html = md;
// Normalize line endings
html = html.replace(/\r\n/g, "\n");
// Escape HTML entities (but we'll re-introduce tags below)
html = html.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// Fenced code blocks (```...```)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
return `<pre class="help-code-block"><code>${code.trimEnd()}</code></pre>`;
});
// Inline code (`...`)
html = html.replace(/`([^`]+)`/g, '<code class="help-inline-code">$1</code>');
// Tables
html = html.replace(
/(?:^|\n)(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)+)/g,
(_m, headerRow: string, _sep: string, bodyRows: string) => {
const headers = headerRow
.split("|")
.slice(1, -1)
.map((c: string) => `<th>${c.trim()}</th>`)
.join("");
const rows = bodyRows
.trim()
.split("\n")
.map((row: string) => {
const cells = row
.split("|")
.slice(1, -1)
.map((c: string) => `<td>${c.trim()}</td>`)
.join("");
return `<tr>${cells}</tr>`;
})
.join("");
return `<table class="help-table"><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`;
},
);
// Blockquotes (> ...)
html = html.replace(/(?:^|\n)&gt; (.+)/g, '<blockquote class="help-blockquote">$1</blockquote>');
// Merge adjacent blockquotes
html = html.replace(/<\/blockquote>\s*<blockquote class="help-blockquote">/g, "<br/>");
// Horizontal rules
html = html.replace(/\n---\n/g, '<hr class="help-hr"/>');
// Headers (process from h4 down to h1)
html = html.replace(/^#### (.+)$/gm, '<h4 class="help-h4">$1</h4>');
html = html.replace(/^### (.+)$/gm, '<h3 class="help-h3">$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2 class="help-h2">$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1 class="help-h1">$1</h1>');
// Bold (**...**)
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
// Italic (*...*)
html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
// Unordered list items (- ...)
// Group consecutive list items
html = html.replace(/((?:^|\n)- .+(?:\n- .+)*)/g, (block) => {
const items = block
.trim()
.split("\n")
.map((line) => `<li>${line.replace(/^- /, "")}</li>`)
.join("");
return `<ul class="help-ul">${items}</ul>`;
});
// Ordered list items (1. ...)
html = html.replace(/((?:^|\n)\d+\. .+(?:\n\d+\. .+)*)/g, (block) => {
const items = block
.trim()
.split("\n")
.map((line) => `<li>${line.replace(/^\d+\. /, "")}</li>`)
.join("");
return `<ol class="help-ol">${items}</ol>`;
});
// Links - convert URLs to clickable links
html = html.replace(
/(?<!="|'>)(https?:\/\/[^\s<)]+)/g,
'<a class="help-link" href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
);
// Wrap remaining loose text lines in paragraphs
// Split by double newlines for paragraph breaks
const blocks = html.split(/\n\n+/);
html = blocks
.map((block) => {
const trimmed = block.trim();
if (!trimmed) return "";
// Don't wrap blocks that are already HTML elements
if (
/^<(h[1-4]|ul|ol|pre|table|blockquote|hr)/.test(trimmed)
) {
return trimmed;
}
// Wrap in paragraph, replacing single newlines with <br/>
return `<p class="help-p">${trimmed.replace(/\n/g, "<br/>")}</p>`;
})
.join("\n");
return html;
}
export default function HelpDialog({ onClose }: Props) {
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 renderedHtml = renderMarkdown(HELP_MARKDOWN);
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 shadow-xl w-[48rem] max-w-[90vw] max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)] flex-shrink-0">
<h2 className="text-lg font-semibold">How to Use Triple-C</h2>
<button
onClick={onClose}
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
>
Close
</button>
</div>
{/* Scrollable content */}
<div
className="flex-1 overflow-y-auto px-6 py-4 help-content"
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
</div>
</div>
);
}

View File

@@ -4,19 +4,25 @@ import TerminalTabs from "../terminal/TerminalTabs";
import { useAppState } from "../../store/appState";
import { useSettings } from "../../hooks/useSettings";
import UpdateDialog from "../settings/UpdateDialog";
import ImageUpdateDialog from "../settings/ImageUpdateDialog";
import HelpDialog from "./HelpDialog";
export default function TopBar() {
const { dockerAvailable, imageExists, updateInfo, appVersion, setUpdateInfo } = useAppState(
const { dockerAvailable, imageExists, updateInfo, imageUpdateInfo, appVersion, setUpdateInfo, setImageUpdateInfo } = useAppState(
useShallow(s => ({
dockerAvailable: s.dockerAvailable,
imageExists: s.imageExists,
updateInfo: s.updateInfo,
imageUpdateInfo: s.imageUpdateInfo,
appVersion: s.appVersion,
setUpdateInfo: s.setUpdateInfo,
setImageUpdateInfo: s.setImageUpdateInfo,
}))
);
const { appSettings, saveSettings } = useSettings();
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
const [showImageUpdateDialog, setShowImageUpdateDialog] = useState(false);
const [showHelpDialog, setShowHelpDialog] = useState(false);
const handleDismiss = async () => {
if (appSettings && updateInfo) {
@@ -29,6 +35,17 @@ export default function TopBar() {
setShowUpdateDialog(false);
};
const handleImageUpdateDismiss = async () => {
if (appSettings && imageUpdateInfo) {
await saveSettings({
...appSettings,
dismissed_image_digest: imageUpdateInfo.remote_digest,
});
}
setImageUpdateInfo(null);
setShowImageUpdateDialog(false);
};
return (
<>
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
@@ -44,8 +61,24 @@ export default function TopBar() {
Update
</button>
)}
{imageUpdateInfo && (
<button
onClick={() => setShowImageUpdateDialog(true)}
className="px-2 py-0.5 rounded text-xs font-medium bg-[var(--warning,#f59e0b)] text-white hover:opacity-80 transition-colors"
title="A newer container image is available"
>
Image Update
</button>
)}
<StatusDot ok={dockerAvailable === true} label="Docker" />
<StatusDot ok={imageExists === true} label="Image" />
<button
onClick={() => setShowHelpDialog(true)}
title="Help"
className="ml-1 w-5 h-5 flex items-center justify-center rounded-full border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] transition-colors text-xs font-semibold leading-none"
>
?
</button>
</div>
</div>
{showUpdateDialog && updateInfo && (
@@ -56,6 +89,16 @@ export default function TopBar() {
onClose={() => setShowUpdateDialog(false)}
/>
)}
{showImageUpdateDialog && imageUpdateInfo && (
<ImageUpdateDialog
imageUpdateInfo={imageUpdateInfo}
onDismiss={handleImageUpdateDismiss}
onClose={() => setShowImageUpdateDialog(false)}
/>
)}
{showHelpDialog && (
<HelpDialog onClose={() => setShowHelpDialog(false)} />
)}
</>
);
}

View File

@@ -147,7 +147,7 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
className={inputCls}
/>
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
Set a Docker image to run this MCP server as a container. Leave empty for manual mode.
Set a Docker image to run this MCP server in its own container. Leave empty to run commands inside the project container. Images are pulled automatically if not present.
</p>
</div>
@@ -171,6 +171,14 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
</div>
</div>
{/* Mode description */}
<p className="text-xs text-[var(--text-secondary)] opacity-60">
{transportType === "stdio" && isDocker && "Runs via docker exec in a separate MCP container."}
{transportType === "stdio" && !isDocker && "Runs inside the project container (e.g. npx commands)."}
{transportType === "http" && isDocker && "Runs in a separate container, reached by hostname on the project network."}
{transportType === "http" && !isDocker && "Connects to an MCP server at the URL you specify."}
</p>
{/* Container Port (HTTP+Docker only) */}
{transportType === "http" && isDocker && (
<div>
@@ -183,7 +191,7 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
className={inputCls}
/>
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
Port inside the MCP container (default: 3000)
Port the MCP server listens on inside its container. The URL is auto-generated as http://&lt;container&gt;:&lt;port&gt;/mcp on the project network.
</p>
</div>
)}

View File

@@ -0,0 +1,55 @@
import { useEffect, useRef, useCallback } from "react";
interface Props {
projectName: string;
onConfirm: () => void;
onCancel: () => void;
}
export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }: Props) {
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onCancel]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current) onCancel();
},
[onCancel],
);
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-[24rem] shadow-xl">
<h2 className="text-lg font-semibold mb-3">Remove Project</h2>
<p className="text-sm text-[var(--text-secondary)] mb-5">
Are you sure you want to remove <strong className="text-[var(--text-primary)]">{projectName}</strong>? This will delete the container, config volume, and stored credentials.
</p>
<div className="flex justify-end gap-2">
<button
onClick={onCancel}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 text-sm text-white bg-[var(--error)] hover:opacity-80 rounded transition-colors"
>
Remove
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,197 @@
import { useEffect, useRef, useCallback } from "react";
import { useFileManager } from "../../hooks/useFileManager";
interface Props {
projectId: string;
projectName: string;
onClose: () => void;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
export default function FileManagerModal({ projectId, projectName, onClose }: Props) {
const {
currentPath,
entries,
loading,
error,
navigate,
goUp,
refresh,
downloadFile,
uploadFile,
} = useFileManager(projectId);
const overlayRef = useRef<HTMLDivElement>(null);
// Load initial directory
useEffect(() => {
navigate("/workspace");
}, [navigate]);
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],
);
// Build breadcrumbs from current path
const breadcrumbs = currentPath === "/"
? [{ label: "/", path: "/" }]
: currentPath.split("/").reduce<{ label: string; path: string }[]>((acc, part, i) => {
if (i === 0) {
acc.push({ label: "/", path: "/" });
} else if (part) {
const parentPath = acc[acc.length - 1].path;
const fullPath = parentPath === "/" ? `/${part}` : `${parentPath}/${part}`;
acc.push({ label: part, path: fullPath });
}
return acc;
}, []);
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 shadow-xl w-[36rem] max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-color)]">
<h2 className="text-sm font-semibold">Files {projectName}</h2>
<button
onClick={onClose}
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
×
</button>
</div>
{/* Path bar */}
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
{breadcrumbs.map((crumb, i) => (
<span key={crumb.path} className="flex items-center gap-1">
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
<button
onClick={() => navigate(crumb.path)}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap"
>
{crumb.label}
</button>
</span>
))}
<div className="flex-1" />
<button
onClick={refresh}
disabled={loading}
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50 px-1"
title="Refresh"
>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0">
{error && (
<div className="px-4 py-2 text-xs text-[var(--error)]">{error}</div>
)}
{loading && entries.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-[var(--text-secondary)]">
Loading...
</div>
) : (
<table className="w-full text-xs">
<tbody>
{/* Go up entry */}
{currentPath !== "/" && (
<tr
onClick={() => goUp()}
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
>
<td className="px-4 py-1.5 text-[var(--text-primary)]">..</td>
<td></td>
<td></td>
<td></td>
</tr>
)}
{entries.map((entry) => (
<tr
key={entry.name}
onClick={() => entry.is_directory && navigate(entry.path)}
className={`${
entry.is_directory ? "cursor-pointer" : ""
} hover:bg-[var(--bg-tertiary)] transition-colors`}
>
<td className="px-4 py-1.5">
<span className={entry.is_directory ? "text-[var(--accent)]" : "text-[var(--text-primary)]"}>
{entry.is_directory ? "📁 " : ""}{entry.name}
</span>
</td>
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap">
{!entry.is_directory && formatSize(entry.size)}
</td>
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
{entry.modified}
</td>
<td className="px-2 py-1.5 text-right">
{!entry.is_directory && (
<button
onClick={(e) => {
e.stopPropagation();
downloadFile(entry);
}}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors px-1"
title="Download"
>
</button>
)}
</td>
</tr>
))}
{entries.length === 0 && !loading && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-[var(--text-secondary)]">
Empty directory
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-color)]">
<button
onClick={uploadFile}
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
>
Upload file
</button>
<button
onClick={onClose}
className="px-4 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}

View File

@@ -57,7 +57,7 @@ const mockProject: Project = {
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
container_id: null,
status: "stopped",
auth_mode: "anthropic",
backend: "anthropic",
bedrock_config: null,
allow_docker_access: false,
ssh_key_path: null,

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { listen } from "@tauri-apps/api/event";
import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, LiteLlmConfig } from "../../lib/types";
import { useProjects } from "../../hooks/useProjects";
import { useMcpServers } from "../../hooks/useMcpServers";
import { useTerminal } from "../../hooks/useTerminal";
@@ -10,6 +10,9 @@ import EnvVarsModal from "./EnvVarsModal";
import PortMappingsModal from "./PortMappingsModal";
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
import ContainerProgressModal from "./ContainerProgressModal";
import FileManagerModal from "./FileManagerModal";
import ConfirmRemoveModal from "./ConfirmRemoveModal";
import Tooltip from "../ui/Tooltip";
interface Props {
project: Project;
@@ -27,9 +30,13 @@ export default function ProjectCard({ project }: Props) {
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
const [showFileManager, setShowFileManager] = useState(false);
const [progressMsg, setProgressMsg] = useState<string | null>(null);
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
const [operationCompleted, setOperationCompleted] = useState(false);
const [showRemoveModal, setShowRemoveModal] = useState(false);
const [isEditingName, setIsEditingName] = useState(false);
const [editName, setEditName] = useState(project.name);
const isSelected = selectedProjectId === project.id;
const isStopped = project.status === "stopped" || project.status === "error";
@@ -52,8 +59,18 @@ export default function ProjectCard({ project }: Props) {
const [bedrockBearerToken, setBedrockBearerToken] = useState(project.bedrock_config?.aws_bearer_token ?? "");
const [bedrockModelId, setBedrockModelId] = useState(project.bedrock_config?.model_id ?? "");
// Ollama local state
const [ollamaBaseUrl, setOllamaBaseUrl] = useState(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
const [ollamaModelId, setOllamaModelId] = useState(project.ollama_config?.model_id ?? "");
// LiteLLM local state
const [litellmBaseUrl, setLitellmBaseUrl] = useState(project.litellm_config?.base_url ?? "http://host.docker.internal:4000");
const [litellmApiKey, setLitellmApiKey] = useState(project.litellm_config?.api_key ?? "");
const [litellmModelId, setLitellmModelId] = useState(project.litellm_config?.model_id ?? "");
// Sync local state when project prop changes (e.g., after save or external update)
useEffect(() => {
setEditName(project.name);
setPaths(project.paths ?? []);
setSshKeyPath(project.ssh_key_path ?? "");
setGitName(project.git_user_name ?? "");
@@ -69,6 +86,11 @@ export default function ProjectCard({ project }: Props) {
setBedrockProfile(project.bedrock_config?.aws_profile ?? "");
setBedrockBearerToken(project.bedrock_config?.aws_bearer_token ?? "");
setBedrockModelId(project.bedrock_config?.model_id ?? "");
setOllamaBaseUrl(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
setOllamaModelId(project.ollama_config?.model_id ?? "");
setLitellmBaseUrl(project.litellm_config?.base_url ?? "http://host.docker.internal:4000");
setLitellmApiKey(project.litellm_config?.api_key ?? "");
setLitellmModelId(project.litellm_config?.model_id ?? "");
}, [project]);
// Listen for container progress events
@@ -135,6 +157,14 @@ export default function ProjectCard({ project }: Props) {
}
};
const handleOpenBashShell = async () => {
try {
await openTerminal(project.id, project.name, "bash");
} catch (e) {
setError(String(e));
}
};
const handleForceStop = async () => {
try {
await stop(project.id);
@@ -162,12 +192,29 @@ export default function ProjectCard({ project }: Props) {
disable_prompt_caching: false,
};
const handleAuthModeChange = async (mode: AuthMode) => {
const defaultOllamaConfig: OllamaConfig = {
base_url: "http://host.docker.internal:11434",
model_id: null,
};
const defaultLiteLlmConfig: LiteLlmConfig = {
base_url: "http://host.docker.internal:4000",
api_key: null,
model_id: null,
};
const handleBackendChange = async (mode: Backend) => {
try {
const updates: Partial<Project> = { auth_mode: mode };
const updates: Partial<Project> = { backend: mode };
if (mode === "bedrock" && !project.bedrock_config) {
updates.bedrock_config = defaultBedrockConfig;
}
if (mode === "ollama" && !project.ollama_config) {
updates.ollama_config = defaultOllamaConfig;
}
if (mode === "lite_llm" && !project.litellm_config) {
updates.litellm_config = defaultLiteLlmConfig;
}
await update({ ...project, ...updates });
} catch (e) {
setError(String(e));
@@ -290,6 +337,51 @@ export default function ProjectCard({ project }: Props) {
}
};
const handleOllamaBaseUrlBlur = async () => {
try {
const current = project.ollama_config ?? defaultOllamaConfig;
await update({ ...project, ollama_config: { ...current, base_url: ollamaBaseUrl } });
} catch (err) {
console.error("Failed to update Ollama base URL:", err);
}
};
const handleOllamaModelIdBlur = async () => {
try {
const current = project.ollama_config ?? defaultOllamaConfig;
await update({ ...project, ollama_config: { ...current, model_id: ollamaModelId || null } });
} catch (err) {
console.error("Failed to update Ollama model ID:", err);
}
};
const handleLitellmBaseUrlBlur = async () => {
try {
const current = project.litellm_config ?? defaultLiteLlmConfig;
await update({ ...project, litellm_config: { ...current, base_url: litellmBaseUrl } });
} catch (err) {
console.error("Failed to update LiteLLM base URL:", err);
}
};
const handleLitellmApiKeyBlur = async () => {
try {
const current = project.litellm_config ?? defaultLiteLlmConfig;
await update({ ...project, litellm_config: { ...current, api_key: litellmApiKey || null } });
} catch (err) {
console.error("Failed to update LiteLLM API key:", err);
}
};
const handleLitellmModelIdBlur = async () => {
try {
const current = project.litellm_config ?? defaultLiteLlmConfig;
await update({ ...project, litellm_config: { ...current, model_id: litellmModelId || null } });
} catch (err) {
console.error("Failed to update LiteLLM model ID:", err);
}
};
const statusColor = {
stopped: "bg-[var(--text-secondary)]",
starting: "bg-[var(--warning)]",
@@ -309,7 +401,41 @@ export default function ProjectCard({ project }: Props) {
>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} />
<span className="text-sm font-medium truncate flex-1">{project.name}</span>
{isEditingName ? (
<input
autoFocus
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={async () => {
setIsEditingName(false);
const trimmed = editName.trim();
if (trimmed && trimmed !== project.name) {
try {
await update({ ...project, name: trimmed });
} catch (err) {
console.error("Failed to rename project:", err);
setEditName(project.name);
}
} else {
setEditName(project.name);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
if (e.key === "Escape") { setEditName(project.name); setIsEditingName(false); }
}}
onClick={(e) => e.stopPropagation()}
className="text-sm font-medium flex-1 min-w-0 px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded text-[var(--text-primary)] focus:outline-none"
/>
) : (
<span
className="text-sm font-medium truncate flex-1 cursor-text"
title="Double-click to rename"
onDoubleClick={(e) => { e.stopPropagation(); setIsEditingName(true); }}
>
{project.name}
</span>
)}
</div>
<div className="mt-0.5 ml-4 space-y-0.5">
{project.paths.map((pp, i) => (
@@ -321,31 +447,21 @@ export default function ProjectCard({ project }: Props) {
{isSelected && (
<div className="mt-2 ml-4 space-y-2 min-w-0 overflow-hidden">
{/* Auth mode selector */}
{/* Backend selector */}
<div className="flex items-center gap-1 text-xs">
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
<button
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("anthropic"); }}
<span className="text-[var(--text-secondary)] mr-1">Backend:<Tooltip text="Choose the AI model provider for this project. Anthropic: Connect directly to Claude via OAuth login (run 'claude login' in terminal). Bedrock: Route through AWS Bedrock using your AWS credentials. Ollama: Use locally-hosted open-source models (Llama, Mistral, etc.) via an Ollama server. LiteLLM: Connect through a LiteLLM proxy gateway to access 100+ model providers (OpenAI, Azure, Gemini, etc.)." /></span>
<select
value={project.backend}
onChange={(e) => { e.stopPropagation(); handleBackendChange(e.target.value as Backend); }}
onClick={(e) => e.stopPropagation()}
disabled={!isStopped}
className={`px-2 py-0.5 rounded transition-colors ${
project.auth_mode === "anthropic"
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
} disabled:opacity-50`}
className="px-2 py-0.5 rounded bg-[var(--bg-primary)] border border-[var(--border-color)] text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
>
Anthropic
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }}
disabled={!isStopped}
className={`px-2 py-0.5 rounded transition-colors ${
project.auth_mode === "bedrock"
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
} disabled:opacity-50`}
>
Bedrock
</button>
<option value="anthropic">Anthropic</option>
<option value="bedrock">Bedrock</option>
<option value="ollama">Ollama</option>
<option value="lite_llm">LiteLLM</option>
</select>
</div>
{/* Action buttons */}
@@ -371,6 +487,8 @@ export default function ProjectCard({ project }: Props) {
<>
<ActionButton onClick={handleStop} disabled={loading} label="Stop" />
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
<ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" />
<ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" />
</>
) : (
<>
@@ -386,11 +504,7 @@ export default function ProjectCard({ project }: Props) {
label={showConfig ? "Hide" : "Config"}
/>
<ActionButton
onClick={async () => {
if (confirm(`Remove project "${project.name}"?`)) {
await remove(project.id);
}
}}
onClick={() => setShowRemoveModal(true)}
disabled={loading}
label="Remove"
danger
@@ -496,7 +610,7 @@ export default function ProjectCard({ project }: Props) {
{/* SSH Key */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory<Tooltip text="Path to your .ssh directory. Mounted into the container so Claude can authenticate with Git remotes over SSH." /></label>
<div className="flex gap-1">
<input
value={sshKeyPath}
@@ -518,7 +632,7 @@ export default function ProjectCard({ project }: Props) {
{/* Git Name */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name<Tooltip text="Sets git user.name inside the container for commit authorship." /></label>
<input
value={gitName}
onChange={(e) => setGitName(e.target.value)}
@@ -531,7 +645,7 @@ export default function ProjectCard({ project }: Props) {
{/* Git Email */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email<Tooltip text="Sets git user.email inside the container for commit authorship." /></label>
<input
value={gitEmail}
onChange={(e) => setGitEmail(e.target.value)}
@@ -544,7 +658,7 @@ export default function ProjectCard({ project }: Props) {
{/* Git Token (HTTPS) */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token<Tooltip text="A personal access token (e.g. GitHub PAT) for HTTPS git operations inside the container." /></label>
<input
type="password"
value={gitToken}
@@ -558,7 +672,7 @@ export default function ProjectCard({ project }: Props) {
{/* Docker access toggle */}
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning</label>
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning<Tooltip text="Mounts the Docker socket so Claude can build and run Docker containers from inside the sandbox." /></label>
<button
onClick={async () => {
try { await update({ ...project, allow_docker_access: !project.allow_docker_access }); } catch (err) {
@@ -576,10 +690,32 @@ export default function ProjectCard({ project }: Props) {
</button>
</div>
{/* Mission Control toggle */}
<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>
<button
onClick={async () => {
try {
await update({ ...project, mission_control_enabled: !project.mission_control_enabled });
} catch (err) {
console.error("Failed to update Mission Control setting:", err);
}
}}
disabled={!isStopped}
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
project.mission_control_enabled
? "bg-[var(--success)] text-white"
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
}`}
>
{project.mission_control_enabled ? "ON" : "OFF"}
</button>
</div>
{/* Environment Variables */}
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}<Tooltip text="Custom env vars injected into this project's container. Useful for API keys or tool configuration." />
</label>
<button
onClick={() => setShowEnvVarsModal(true)}
@@ -592,7 +728,7 @@ export default function ProjectCard({ project }: Props) {
{/* Port Mappings */}
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}<Tooltip text="Map container ports to host ports so you can access dev servers running inside the container." />
</label>
<button
onClick={() => setShowPortMappingsModal(true)}
@@ -605,7 +741,7 @@ export default function ProjectCard({ project }: Props) {
{/* Claude Instructions */}
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">
Claude Instructions{claudeInstructions ? " (set)" : ""}
Claude Instructions{claudeInstructions ? " (set)" : ""}<Tooltip text="Project-specific instructions written to CLAUDE.md. Guides Claude's behavior for this project." />
</label>
<button
onClick={() => setShowClaudeInstructionsModal(true)}
@@ -618,7 +754,7 @@ export default function ProjectCard({ project }: Props) {
{/* MCP Servers */}
{mcpServers.length > 0 && (
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-1">MCP Servers</label>
<label className="block text-xs text-[var(--text-secondary)] mb-1">MCP Servers<Tooltip text="Model Context Protocol servers give Claude access to external tools and data sources." /></label>
<div className="space-y-1">
{mcpServers.map((server) => {
const enabled = project.enabled_mcp_servers.includes(server.id);
@@ -659,7 +795,7 @@ export default function ProjectCard({ project }: Props) {
)}
{/* Bedrock config */}
{project.auth_mode === "bedrock" && (() => {
{project.backend === "bedrock" && (() => {
const bc = project.bedrock_config ?? defaultBedrockConfig;
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50";
return (
@@ -669,25 +805,22 @@ export default function ProjectCard({ project }: Props) {
{/* Sub-method selector */}
<div className="flex items-center gap-1 text-xs">
<span className="text-[var(--text-secondary)] mr-1">Method:</span>
{(["static_credentials", "profile", "bearer_token"] as BedrockAuthMethod[]).map((m) => (
<button
key={m}
onClick={() => updateBedrockConfig({ auth_method: m })}
<select
value={bc.auth_method}
onChange={(e) => updateBedrockConfig({ auth_method: e.target.value as BedrockAuthMethod })}
onClick={(e) => e.stopPropagation()}
disabled={!isStopped}
className={`px-2 py-0.5 rounded transition-colors ${
bc.auth_method === m
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
} disabled:opacity-50`}
className="px-2 py-0.5 rounded bg-[var(--bg-primary)] border border-[var(--border-color)] text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
>
{m === "static_credentials" ? "Keys" : m === "profile" ? "Profile" : "Token"}
</button>
))}
<option value="static_credentials">Keys</option>
<option value="profile">Profile</option>
<option value="bearer_token">Token</option>
</select>
</div>
{/* AWS Region (always shown) */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region<Tooltip text="The AWS region where your Bedrock endpoint is available (e.g. us-east-1)." /></label>
<input
value={bedrockRegion}
onChange={(e) => setBedrockRegion(e.target.value)}
@@ -702,7 +835,7 @@ export default function ProjectCard({ project }: Props) {
{bc.auth_method === "static_credentials" && (
<>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID<Tooltip text="Your AWS IAM access key ID for Bedrock API authentication." /></label>
<input
value={bedrockAccessKeyId}
onChange={(e) => setBedrockAccessKeyId(e.target.value)}
@@ -713,7 +846,7 @@ export default function ProjectCard({ project }: Props) {
/>
</div>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key<Tooltip text="Your AWS IAM secret key. Stored locally and injected as an env var into the container." /></label>
<input
type="password"
value={bedrockSecretKey}
@@ -724,7 +857,7 @@ export default function ProjectCard({ project }: Props) {
/>
</div>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)<Tooltip text="Temporary session token for assumed-role or MFA-based AWS credentials." /></label>
<input
type="password"
value={bedrockSessionToken}
@@ -740,7 +873,7 @@ export default function ProjectCard({ project }: Props) {
{/* Profile field */}
{bc.auth_method === "profile" && (
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile<Tooltip text="Named profile from your AWS config/credentials files (e.g. 'default' or 'prod')." /></label>
<input
value={bedrockProfile}
onChange={(e) => setBedrockProfile(e.target.value)}
@@ -755,7 +888,7 @@ export default function ProjectCard({ project }: Props) {
{/* Bearer token field */}
{bc.auth_method === "bearer_token" && (
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token<Tooltip text="An SSO or identity-center bearer token for Bedrock authentication." /></label>
<input
type="password"
value={bedrockBearerToken}
@@ -769,7 +902,7 @@ export default function ProjectCard({ project }: Props) {
{/* Model override */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)<Tooltip text="Override the default Bedrock model. Leave blank to use Claude's default." /></label>
<input
value={bedrockModelId}
onChange={(e) => setBedrockModelId(e.target.value)}
@@ -782,6 +915,99 @@ export default function ProjectCard({ project }: Props) {
</div>
);
})()}
{/* Ollama config */}
{project.backend === "ollama" && (() => {
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50";
return (
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]">
<label className="block text-xs font-medium text-[var(--text-primary)]">Ollama</label>
<p className="text-xs text-[var(--text-secondary)]">
Connect to an Ollama server running locally or on a remote host.
</p>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL<Tooltip text="URL of your Ollama server. Use host.docker.internal to reach the host machine from inside the container." /></label>
<input
value={ollamaBaseUrl}
onChange={(e) => setOllamaBaseUrl(e.target.value)}
onBlur={handleOllamaBaseUrlBlur}
placeholder="http://host.docker.internal:11434"
disabled={!isStopped}
className={inputCls}
/>
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-70">
Use host.docker.internal for the host machine, or an IP/hostname for remote.
</p>
</div>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (optional)<Tooltip text="Ollama model name to use (e.g. qwen3.5:27b). Leave blank for the server default." /></label>
<input
value={ollamaModelId}
onChange={(e) => setOllamaModelId(e.target.value)}
onBlur={handleOllamaModelIdBlur}
placeholder="qwen3.5:27b"
disabled={!isStopped}
className={inputCls}
/>
</div>
</div>
);
})()}
{/* LiteLLM config */}
{project.backend === "lite_llm" && (() => {
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50";
return (
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]">
<label className="block text-xs font-medium text-[var(--text-primary)]">LiteLLM Gateway</label>
<p className="text-xs text-[var(--text-secondary)]">
Connect through a LiteLLM proxy to use 100+ model providers.
</p>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL<Tooltip text="URL of your LiteLLM proxy server. Use host.docker.internal for a locally running proxy." /></label>
<input
value={litellmBaseUrl}
onChange={(e) => setLitellmBaseUrl(e.target.value)}
onBlur={handleLitellmBaseUrlBlur}
placeholder="http://host.docker.internal:4000"
disabled={!isStopped}
className={inputCls}
/>
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-70">
Use host.docker.internal for local, or a URL for remote/containerized LiteLLM.
</p>
</div>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">API Key<Tooltip text="Authentication key for your LiteLLM proxy, if required." /></label>
<input
type="password"
value={litellmApiKey}
onChange={(e) => setLitellmApiKey(e.target.value)}
onBlur={handleLitellmApiKeyBlur}
placeholder="sk-..."
disabled={!isStopped}
className={inputCls}
/>
</div>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (optional)<Tooltip text="Model identifier as configured in your LiteLLM proxy (e.g. gpt-4o, gemini-pro)." /></label>
<input
value={litellmModelId}
onChange={(e) => setLitellmModelId(e.target.value)}
onBlur={handleLitellmModelIdBlur}
placeholder="gpt-4o / gemini-pro / etc."
disabled={!isStopped}
className={inputCls}
/>
</div>
</div>
);
})()}
</div>
)}
</div>
@@ -827,6 +1053,25 @@ export default function ProjectCard({ project }: Props) {
/>
)}
{showFileManager && (
<FileManagerModal
projectId={project.id}
projectName={project.name}
onClose={() => setShowFileManager(false)}
/>
)}
{showRemoveModal && (
<ConfirmRemoveModal
projectName={project.name}
onConfirm={async () => {
setShowRemoveModal(false);
await remove(project.id);
}}
onCancel={() => setShowRemoveModal(false)}
/>
)}
{activeOperation && (
<ContainerProgressModal
projectName={project.name}
@@ -869,3 +1114,4 @@ function ActionButton({
</button>
);
}

View File

@@ -1,9 +1,9 @@
export default function ApiKeyInput() {
return (
<div>
<label className="block text-sm font-medium mb-1">Authentication</label>
<label className="block text-sm font-medium mb-1">Backend</label>
<p className="text-xs text-[var(--text-secondary)] mb-3">
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal) or <strong>AWS Bedrock</strong>. Set auth mode per-project.
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal) or <strong>AWS Bedrock</strong>. Set backend per-project.
</p>
</div>
);

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { useSettings } from "../../hooks/useSettings";
import * as commands from "../../lib/tauri-commands";
import Tooltip from "../ui/Tooltip";
export default function AwsSettings() {
const { appSettings, saveSettings } = useSettings();
@@ -56,7 +57,7 @@ export default function AwsSettings() {
{/* AWS Config Path */}
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">AWS Config Path</span>
<span className="text-[var(--text-secondary)] text-xs block mb-1">AWS Config Path<Tooltip text="Path to your AWS config/credentials directory. Mounted into containers for Bedrock auth." /></span>
<div className="flex gap-2">
<input
type="text"
@@ -80,7 +81,7 @@ export default function AwsSettings() {
{/* AWS Profile */}
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Profile</span>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Profile<Tooltip text="AWS named profile to use by default. Per-project settings can override this." /></span>
<select
value={globalAws.aws_profile ?? ""}
onChange={(e) => handleChange("aws_profile", e.target.value)}
@@ -95,7 +96,7 @@ export default function AwsSettings() {
{/* AWS Region */}
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Region</span>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Region<Tooltip text="Default AWS region for Bedrock API calls (e.g. us-east-1). Can be overridden per project." /></span>
<input
type="text"
value={globalAws.aws_region ?? ""}

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { useDocker } from "../../hooks/useDocker";
import { useSettings } from "../../hooks/useSettings";
import type { ImageSource } from "../../lib/types";
import Tooltip from "../ui/Tooltip";
const REGISTRY_IMAGE = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest";
@@ -87,7 +88,7 @@ export default function DockerSettings() {
{/* Image Source Selector */}
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1.5">Image Source</span>
<span className="text-[var(--text-secondary)] text-xs block mb-1.5">Image Source<Tooltip text="Registry pulls the pre-built image. Local Build compiles from the bundled Dockerfile. Custom lets you specify any image." /></span>
<div className="flex gap-1">
{IMAGE_SOURCE_OPTIONS.map((opt) => (
<button
@@ -109,7 +110,7 @@ export default function DockerSettings() {
{/* Custom image input */}
{imageSource === "custom" && (
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Custom Image</span>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Custom Image<Tooltip text="Full image name including registry and tag (e.g. myregistry.com/image:tag)." /></span>
<input
type="text"
value={customInput}
@@ -121,9 +122,9 @@ export default function DockerSettings() {
)}
{/* Resolved image display */}
<div className="flex items-center justify-between">
<div>
<span className="text-[var(--text-secondary)]">Image</span>
<span className="text-xs text-[var(--text-secondary)] truncate max-w-[200px]" title={resolvedImageName}>
<span className="block text-xs text-[var(--text-secondary)] font-mono mt-0.5 truncate" title={resolvedImageName}>
{resolvedImageName}
</span>
</div>

View File

@@ -0,0 +1,91 @@
import { useEffect, useRef, useCallback } from "react";
import type { ImageUpdateInfo } from "../../lib/types";
interface Props {
imageUpdateInfo: ImageUpdateInfo;
onDismiss: () => void;
onClose: () => void;
}
export default function ImageUpdateDialog({
imageUpdateInfo,
onDismiss,
onClose,
}: Props) {
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 shortDigest = (digest: string) => {
// Show first 16 chars of the hash part (after "sha256:")
const hash = digest.startsWith("sha256:") ? digest.slice(7) : digest;
return hash.slice(0, 16);
};
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-[28rem] max-h-[80vh] overflow-y-auto shadow-xl">
<h2 className="text-lg font-semibold mb-3">Container Image Update</h2>
<p className="text-sm text-[var(--text-secondary)] mb-4">
A newer version of the container image is available in the registry.
Re-pull the image in Docker settings to get the latest tools and fixes.
</p>
<div className="space-y-2 mb-4 text-xs bg-[var(--bg-primary)] rounded p-3 border border-[var(--border-color)]">
{imageUpdateInfo.local_digest && (
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Local digest</span>
<span className="font-mono text-[var(--text-primary)]">
{shortDigest(imageUpdateInfo.local_digest)}...
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Remote digest</span>
<span className="font-mono text-[var(--accent)]">
{shortDigest(imageUpdateInfo.remote_digest)}...
</span>
</div>
</div>
<p className="text-xs text-[var(--text-secondary)] mb-4">
Go to Settings &gt; Docker and click &quot;Re-pull Image&quot; to update.
Running containers will not be affected until restarted.
</p>
<div className="flex items-center justify-end gap-2">
<button
onClick={onDismiss}
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Dismiss
</button>
<button
onClick={onClose}
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { useState, useEffect, useCallback } from "react";
import { useSettings } from "../../hooks/useSettings";
interface AudioDevice {
deviceId: string;
label: string;
}
export default function MicrophoneSettings() {
const { appSettings, saveSettings } = useSettings();
const [devices, setDevices] = useState<AudioDevice[]>([]);
const [selected, setSelected] = useState(appSettings?.default_microphone ?? "");
const [loading, setLoading] = useState(false);
const [permissionNeeded, setPermissionNeeded] = useState(false);
// Sync local state when appSettings change
useEffect(() => {
setSelected(appSettings?.default_microphone ?? "");
}, [appSettings?.default_microphone]);
const enumerateDevices = useCallback(async () => {
setLoading(true);
setPermissionNeeded(false);
try {
// Request mic permission first so device labels are available
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach((t) => t.stop());
const allDevices = await navigator.mediaDevices.enumerateDevices();
const mics = allDevices
.filter((d) => d.kind === "audioinput")
.map((d) => ({
deviceId: d.deviceId,
label: d.label || `Microphone (${d.deviceId.slice(0, 8)}...)`,
}));
setDevices(mics);
} catch {
setPermissionNeeded(true);
} finally {
setLoading(false);
}
}, []);
// Enumerate devices on mount
useEffect(() => {
enumerateDevices();
}, [enumerateDevices]);
const handleChange = async (deviceId: string) => {
setSelected(deviceId);
if (appSettings) {
await saveSettings({ ...appSettings, default_microphone: deviceId || null });
}
};
return (
<div>
<label className="block text-sm font-medium mb-1">Microphone</label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Audio input device for Claude Code voice mode (/voice)
</p>
{permissionNeeded ? (
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--text-secondary)]">
Microphone permission required
</span>
<button
onClick={enumerateDevices}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Grant Access
</button>
</div>
) : (
<div className="flex items-center gap-2">
<select
value={selected}
onChange={(e) => handleChange(e.target.value)}
disabled={loading}
className="flex-1 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
>
<option value="">System Default</option>
{devices.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label}
</option>
))}
</select>
<button
onClick={enumerateDevices}
disabled={loading}
title="Refresh microphone list"
className="text-xs px-2 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors disabled:opacity-50"
>
{loading ? "..." : "Refresh"}
</button>
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import ApiKeyInput from "./ApiKeyInput";
import DockerSettings from "./DockerSettings";
import AwsSettings from "./AwsSettings";
import { useSettings } from "../../hooks/useSettings";
@@ -8,10 +7,11 @@ import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
import EnvVarsModal from "../projects/EnvVarsModal";
import { detectHostTimezone } from "../../lib/tauri-commands";
import type { EnvVar } from "../../lib/types";
import Tooltip from "../ui/Tooltip";
export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings();
const { appVersion, checkForUpdates } = useUpdates();
const { appVersion, imageUpdateInfo, checkForUpdates, checkImageUpdate } = useUpdates();
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
const [checkingUpdates, setCheckingUpdates] = useState(false);
@@ -39,7 +39,7 @@ export default function SettingsPanel() {
const handleCheckNow = async () => {
setCheckingUpdates(true);
try {
await checkForUpdates();
await Promise.all([checkForUpdates(), checkImageUpdate()]);
} finally {
setCheckingUpdates(false);
}
@@ -55,13 +55,12 @@ export default function SettingsPanel() {
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
Settings
</h2>
<ApiKeyInput />
<DockerSettings />
<AwsSettings />
{/* Container Timezone */}
<div>
<label className="block text-sm font-medium mb-1">Container Timezone</label>
<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>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Timezone for containers affects scheduled task timing (IANA format, e.g. America/New_York)
</p>
@@ -81,7 +80,7 @@ export default function SettingsPanel() {
{/* Global Claude Instructions */}
<div>
<label className="block text-sm font-medium mb-1">Claude Instructions</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">
Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
</p>
@@ -100,7 +99,7 @@ export default function SettingsPanel() {
{/* Global Environment Variables */}
<div>
<label className="block text-sm font-medium mb-1">Global Environment Variables</label>
<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>
@@ -119,7 +118,7 @@ export default function SettingsPanel() {
{/* Updates section */}
<div>
<label className="block text-sm font-medium mb-2">Updates</label>
<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="space-y-2">
{appVersion && (
<p className="text-xs text-[var(--text-secondary)]">
@@ -146,6 +145,12 @@ export default function SettingsPanel() {
>
{checkingUpdates ? "Checking..." : "Check now"}
</button>
{imageUpdateInfo && (
<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>A newer container image is available. Re-pull the image in Docker settings above to update.</span>
</div>
)}
</div>
</div>

View File

@@ -23,7 +23,9 @@ export default function TerminalTabs() {
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
<span className="truncate max-w-[120px]">{session.projectName}</span>
<span className="truncate max-w-[120px]">
{session.projectName}{session.sessionType === "bash" ? " (bash)" : ""}
</span>
<button
onClick={(e) => {
e.stopPropagation();

View File

@@ -6,6 +6,8 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState";
import { awsSsoRefresh } from "../../lib/tauri-commands";
import { UrlDetector } from "../../lib/urlDetector";
import UrlToast from "./UrlToast";
@@ -23,6 +25,12 @@ export default function TerminalView({ sessionId, active }: Props) {
const detectorRef = useRef<UrlDetector | null>(null);
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const ssoBufferRef = useRef("");
const ssoTriggeredRef = useRef(false);
const projectId = useAppState(
(s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId
);
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
@@ -82,6 +90,25 @@ export default function TerminalView({ sessionId, active }: Props) {
// Send initial size
resize(sessionId, term.cols, term.rows);
// Handle OSC 52 clipboard write sequences from programs inside the container.
// When a program (e.g. Claude Code) copies text via xclip/xsel/pbcopy, the
// container's shim emits an OSC 52 escape sequence which xterm.js routes here.
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
const idx = data.indexOf(";");
if (idx === -1) return false;
const payload = data.substring(idx + 1);
if (payload === "?") return false; // clipboard read request, not supported
try {
const decoded = atob(payload);
navigator.clipboard.writeText(decoded).catch((e) =>
console.error("OSC 52 clipboard write failed:", e),
);
} catch (e) {
console.error("OSC 52 decode failed:", e);
}
return true;
});
// Handle user input -> backend
const inputDisposable = term.onData((data) => {
sendInput(sessionId, data);
@@ -133,10 +160,30 @@ export default function TerminalView({ sessionId, active }: Props) {
const detector = new UrlDetector((url) => setDetectedUrl(url));
detectorRef.current = detector;
const SSO_MARKER = "###TRIPLE_C_SSO_REFRESH###";
const textDecoder = new TextDecoder();
const outputPromise = onOutput(sessionId, (data) => {
if (aborted) return;
term.write(data);
detector.feed(data);
// Scan for SSO refresh marker in terminal output
if (!ssoTriggeredRef.current && projectId) {
const text = textDecoder.decode(data, { stream: true });
// Combine with overlap from previous chunk to handle marker spanning chunks
const combined = ssoBufferRef.current + text;
if (combined.includes(SSO_MARKER)) {
ssoTriggeredRef.current = true;
ssoBufferRef.current = "";
awsSsoRefresh(projectId).catch((e) =>
console.error("AWS SSO refresh failed:", e)
);
} else {
// Keep last N chars as overlap for next chunk
ssoBufferRef.current = combined.slice(-SSO_MARKER.length);
}
}
}).then((unlisten) => {
if (aborted) unlisten();
return unlisten;
@@ -170,6 +217,9 @@ export default function TerminalView({ sessionId, active }: Props) {
aborted = true;
detector.dispose();
detectorRef.current = null;
ssoTriggeredRef.current = false;
ssoBufferRef.current = "";
osc52Disposable.dispose();
inputDisposable.dispose();
scrollDisposable.dispose();
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* Tests the OSC 52 clipboard parsing logic used in TerminalView.
* Extracted here to validate the decode/write path independently.
*/
// Mirrors the handler registered in TerminalView.tsx
function handleOsc52(data: string): string | null {
const idx = data.indexOf(";");
if (idx === -1) return null;
const payload = data.substring(idx + 1);
if (payload === "?") return null;
try {
return atob(payload);
} catch {
return null;
}
}
describe("OSC 52 clipboard handler", () => {
it("decodes a valid clipboard write sequence", () => {
// "c;BASE64" where BASE64 encodes "https://example.com"
const encoded = btoa("https://example.com");
const result = handleOsc52(`c;${encoded}`);
expect(result).toBe("https://example.com");
});
it("decodes multi-line content", () => {
const text = "line1\nline2\nline3";
const encoded = btoa(text);
const result = handleOsc52(`c;${encoded}`);
expect(result).toBe(text);
});
it("handles primary selection target (p)", () => {
const encoded = btoa("selected text");
const result = handleOsc52(`p;${encoded}`);
expect(result).toBe("selected text");
});
it("returns null for clipboard read request (?)", () => {
expect(handleOsc52("c;?")).toBe(null);
});
it("returns null for missing semicolon", () => {
expect(handleOsc52("invalid")).toBe(null);
});
it("returns null for invalid base64", () => {
expect(handleOsc52("c;!!!not-base64!!!")).toBe(null);
});
it("handles empty payload after selection target", () => {
// btoa("") = ""
const result = handleOsc52("c;");
expect(result).toBe("");
});
});

View File

@@ -0,0 +1,78 @@
import { useState, useRef, useLayoutEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
interface TooltipProps {
text: string;
children?: ReactNode;
}
/**
* A small circled question-mark icon that shows a tooltip on hover.
* Uses a portal to render at `document.body` so the tooltip is never
* clipped by ancestor `overflow: hidden` containers.
*/
export default function Tooltip({ text, children }: TooltipProps) {
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const [, setPlacement] = useState<"top" | "bottom">("top");
const triggerRef = useRef<HTMLSpanElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!visible || !triggerRef.current || !tooltipRef.current) return;
const trigger = triggerRef.current.getBoundingClientRect();
const tooltip = tooltipRef.current.getBoundingClientRect();
const gap = 6;
// Vertical: prefer above, fall back to below
const above = trigger.top - tooltip.height - gap >= 4;
const pos = above ? "top" : "bottom";
setPlacement(pos);
const top =
pos === "top"
? trigger.top - tooltip.height - gap
: trigger.bottom + gap;
// Horizontal: center on trigger, clamp to viewport
let left = trigger.left + trigger.width / 2 - tooltip.width / 2;
left = Math.max(4, Math.min(left, window.innerWidth - tooltip.width - 4));
setCoords({ top, left });
}, [visible]);
return (
<span
ref={triggerRef}
className="inline-flex items-center ml-1"
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
>
{children ?? (
<span
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-[var(--text-secondary)] text-[var(--text-secondary)] text-[9px] leading-none cursor-help select-none hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
aria-label="Help"
>
?
</span>
)}
{visible &&
createPortal(
<div
ref={tooltipRef}
style={{
position: "fixed",
top: coords.top,
left: coords.left,
zIndex: 9999,
}}
className={`px-2.5 py-1.5 text-[11px] leading-snug text-[var(--text-primary)] bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded shadow-lg whitespace-normal max-w-[280px] w-max pointer-events-none`}
>
{text}
</div>,
document.body
)}
</span>
);
}

View File

@@ -0,0 +1,74 @@
import { useState, useCallback } from "react";
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
import type { FileEntry } from "../lib/types";
import * as commands from "../lib/tauri-commands";
export function useFileManager(projectId: string) {
const [currentPath, setCurrentPath] = useState("/workspace");
const [entries, setEntries] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const navigate = useCallback(
async (path: string) => {
setLoading(true);
setError(null);
try {
const result = await commands.listContainerFiles(projectId, path);
setEntries(result);
setCurrentPath(path);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
},
[projectId],
);
const goUp = useCallback(() => {
if (currentPath === "/") return;
const parent = currentPath.replace(/\/[^/]+$/, "") || "/";
navigate(parent);
}, [currentPath, navigate]);
const refresh = useCallback(() => {
navigate(currentPath);
}, [currentPath, navigate]);
const downloadFile = useCallback(
async (entry: FileEntry) => {
try {
const hostPath = await save({ defaultPath: entry.name });
if (!hostPath) return;
await commands.downloadContainerFile(projectId, entry.path, hostPath);
} catch (e) {
setError(String(e));
}
},
[projectId],
);
const uploadFile = useCallback(async () => {
try {
const selected = await openDialog({ multiple: false, directory: false });
if (!selected) return;
await commands.uploadFileToContainer(projectId, selected as string, currentPath);
await navigate(currentPath);
} catch (e) {
setError(String(e));
}
}, [projectId, currentPath, navigate]);
return {
currentPath,
entries,
loading,
error,
navigate,
goUp,
refresh,
downloadFile,
uploadFile,
};
}

View File

@@ -17,10 +17,10 @@ export function useTerminal() {
);
const open = useCallback(
async (projectId: string, projectName: string) => {
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude") => {
const sessionId = crypto.randomUUID();
await commands.openTerminalSession(projectId, sessionId);
addSession({ id: sessionId, projectId, projectName });
await commands.openTerminalSession(projectId, sessionId, sessionType);
addSession({ id: sessionId, projectId, projectName, sessionType });
return sessionId;
},
[addSession],

View File

@@ -6,11 +6,20 @@ import * as commands from "../lib/tauri-commands";
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
export function useUpdates() {
const { updateInfo, setUpdateInfo, appVersion, setAppVersion, appSettings } =
useAppState(
const {
updateInfo,
setUpdateInfo,
imageUpdateInfo,
setImageUpdateInfo,
appVersion,
setAppVersion,
appSettings,
} = useAppState(
useShallow((s) => ({
updateInfo: s.updateInfo,
setUpdateInfo: s.setUpdateInfo,
imageUpdateInfo: s.imageUpdateInfo,
setImageUpdateInfo: s.setImageUpdateInfo,
appVersion: s.appVersion,
setAppVersion: s.setAppVersion,
appSettings: s.appSettings,
@@ -47,11 +56,31 @@ export function useUpdates() {
}
}, [setUpdateInfo, appSettings?.dismissed_update_version]);
const checkImageUpdate = useCallback(async () => {
try {
const info = await commands.checkImageUpdate();
if (info) {
// Respect dismissed image digest
const dismissed = appSettings?.dismissed_image_digest;
if (dismissed && dismissed === info.remote_digest) {
setImageUpdateInfo(null);
return null;
}
}
setImageUpdateInfo(info);
return info;
} catch (e) {
console.error("Failed to check for image updates:", e);
return null;
}
}, [setImageUpdateInfo, appSettings?.dismissed_image_digest]);
const startPeriodicCheck = useCallback(() => {
if (intervalRef.current) return;
intervalRef.current = setInterval(() => {
if (appSettings?.auto_check_updates !== false) {
checkForUpdates();
checkImageUpdate();
}
}, CHECK_INTERVAL_MS);
return () => {
@@ -60,13 +89,15 @@ export function useUpdates() {
intervalRef.current = null;
}
};
}, [checkForUpdates, appSettings?.auto_check_updates]);
}, [checkForUpdates, checkImageUpdate, appSettings?.auto_check_updates]);
return {
updateInfo,
imageUpdateInfo,
appVersion,
loadVersion,
checkForUpdates,
checkImageUpdate,
startPeriodicCheck,
};
}

103
app/src/hooks/useVoice.ts Normal file
View File

@@ -0,0 +1,103 @@
import { useCallback, useRef, useState } from "react";
import * as commands from "../lib/tauri-commands";
type VoiceState = "inactive" | "starting" | "active" | "error";
export function useVoice(sessionId: string, deviceId?: string | null) {
const [state, setState] = useState<VoiceState>("inactive");
const [error, setError] = useState<string | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const workletRef = useRef<AudioWorkletNode | null>(null);
const start = useCallback(async () => {
if (state === "active" || state === "starting") return;
setState("starting");
setError(null);
try {
// 1. Start the audio bridge in the container (creates FIFO writer)
await commands.startAudioBridge(sessionId);
// 2. Get microphone access (use specific device if configured)
const audioConstraints: MediaTrackConstraints = {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
};
if (deviceId) {
audioConstraints.deviceId = { exact: deviceId };
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: audioConstraints,
});
streamRef.current = stream;
// 3. Create AudioContext at 16kHz (browser handles resampling)
const audioContext = new AudioContext({ sampleRate: 16000 });
audioContextRef.current = audioContext;
// 4. Load AudioWorklet processor
await audioContext.audioWorklet.addModule("/audio-capture-processor.js");
// 5. Connect: mic → worklet → (silent) destination
const source = audioContext.createMediaStreamSource(stream);
const processor = new AudioWorkletNode(audioContext, "audio-capture-processor");
workletRef.current = processor;
// 6. Handle PCM chunks from the worklet
processor.port.onmessage = (event: MessageEvent<ArrayBuffer>) => {
const bytes = Array.from(new Uint8Array(event.data));
commands.sendAudioData(sessionId, bytes).catch(() => {
// Audio bridge may have been closed — ignore send errors
});
};
source.connect(processor);
processor.connect(audioContext.destination);
setState("active");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setError(msg);
setState("error");
// Clean up on failure
await commands.stopAudioBridge(sessionId).catch(() => {});
}
}, [sessionId, state, deviceId]);
const stop = useCallback(async () => {
// Tear down audio pipeline
workletRef.current?.disconnect();
workletRef.current = null;
if (audioContextRef.current) {
await audioContextRef.current.close().catch(() => {});
audioContextRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
// Stop the container-side audio bridge
await commands.stopAudioBridge(sessionId).catch(() => {});
setState("inactive");
setError(null);
}, [sessionId]);
const toggle = useCallback(async () => {
if (state === "active") {
await stop();
} else {
await start();
}
}, [state, start, stop]);
return { state, error, start, stop, toggle };
}

View File

@@ -53,3 +53,135 @@ body {
to { opacity: 1; transform: translate(-50%, 0); }
}
.animate-slide-down { animation: slide-down 0.2s ease-out; }
/* Help dialog content styles */
.help-content {
font-size: 0.8125rem;
line-height: 1.6;
color: var(--text-primary);
}
.help-content .help-h1 {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 1rem 0;
color: var(--text-primary);
}
.help-content .help-h2 {
font-size: 1.15rem;
font-weight: 600;
margin: 1.5rem 0 0.75rem 0;
padding-bottom: 0.375rem;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
}
.help-content .help-h3 {
font-size: 0.95rem;
font-weight: 600;
margin: 1.25rem 0 0.5rem 0;
color: var(--text-primary);
}
.help-content .help-h4 {
font-size: 0.875rem;
font-weight: 600;
margin: 1rem 0 0.375rem 0;
color: var(--text-secondary);
}
.help-content .help-p {
margin: 0.5rem 0;
}
.help-content .help-ul,
.help-content .help-ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.help-content .help-ul {
list-style-type: disc;
}
.help-content .help-ol {
list-style-type: decimal;
}
.help-content .help-ul li,
.help-content .help-ol li {
margin: 0.25rem 0;
}
.help-content .help-code-block {
display: block;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.75rem 1rem;
margin: 0.5rem 0;
overflow-x: auto;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.75rem;
line-height: 1.5;
white-space: pre;
}
.help-content .help-inline-code {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 0.125rem 0.375rem;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.75rem;
}
.help-content .help-table {
width: 100%;
border-collapse: collapse;
margin: 0.5rem 0;
font-size: 0.75rem;
}
.help-content .help-table th,
.help-content .help-table td {
border: 1px solid var(--border-color);
padding: 0.375rem 0.625rem;
text-align: left;
}
.help-content .help-table th {
background: var(--bg-tertiary);
font-weight: 600;
}
.help-content .help-table td {
background: var(--bg-primary);
}
.help-content .help-blockquote {
border-left: 3px solid var(--accent);
background: var(--bg-primary);
margin: 0.5rem 0;
padding: 0.5rem 0.75rem;
border-radius: 0 4px 4px 0;
color: var(--text-secondary);
font-size: 0.75rem;
}
.help-content .help-hr {
border: none;
border-top: 1px solid var(--border-color);
margin: 1.5rem 0;
}
.help-content .help-link {
color: var(--accent);
text-decoration: none;
}
.help-content .help-link:hover {
color: var(--accent-hover);
text-decoration: underline;
}

View File

@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, McpServer } from "./types";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -24,6 +24,8 @@ export const stopProjectContainer = (projectId: string) =>
invoke<void>("stop_project_container", { projectId });
export const rebuildProjectContainer = (projectId: string) =>
invoke<Project>("rebuild_project_container", { projectId });
export const reconcileProjectStatuses = () =>
invoke<Project[]>("reconcile_project_statuses");
// Settings
export const getSettings = () => invoke<AppSettings>("get_settings");
@@ -38,9 +40,13 @@ export const listAwsProfiles = () =>
export const detectHostTimezone = () =>
invoke<string>("detect_host_timezone");
// AWS
export const awsSsoRefresh = (projectId: string) =>
invoke<void>("aws_sso_refresh", { projectId });
// Terminal
export const openTerminalSession = (projectId: string, sessionId: string) =>
invoke<void>("open_terminal_session", { projectId, sessionId });
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string) =>
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType });
export const terminalInput = (sessionId: string, data: number[]) =>
invoke<void>("terminal_input", { sessionId, data });
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
@@ -49,6 +55,12 @@ export const closeTerminalSession = (sessionId: string) =>
invoke<void>("close_terminal_session", { sessionId });
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
export const startAudioBridge = (sessionId: string) =>
invoke<void>("start_audio_bridge", { sessionId });
export const sendAudioData = (sessionId: string, data: number[]) =>
invoke<void>("send_audio_data", { sessionId, data });
export const stopAudioBridge = (sessionId: string) =>
invoke<void>("stop_audio_bridge", { sessionId });
// MCP Servers
export const listMcpServers = () => invoke<McpServer[]>("list_mcp_servers");
@@ -59,7 +71,17 @@ export const updateMcpServer = (server: McpServer) =>
export const removeMcpServer = (serverId: string) =>
invoke<void>("remove_mcp_server", { serverId });
// Files
export const listContainerFiles = (projectId: string, path: string) =>
invoke<FileEntry[]>("list_container_files", { projectId, path });
export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) =>
invoke<void>("download_container_file", { projectId, containerPath, hostPath });
export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) =>
invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir });
// Updates
export const getAppVersion = () => invoke<string>("get_app_version");
export const checkForUpdates = () =>
invoke<UpdateInfo | null>("check_for_updates");
export const checkImageUpdate = () =>
invoke<ImageUpdateInfo | null>("check_image_update");

View File

@@ -20,9 +20,12 @@ export interface Project {
paths: ProjectPath[];
container_id: string | null;
status: ProjectStatus;
auth_mode: AuthMode;
backend: Backend;
bedrock_config: BedrockConfig | null;
ollama_config: OllamaConfig | null;
litellm_config: LiteLlmConfig | null;
allow_docker_access: boolean;
mission_control_enabled: boolean;
ssh_key_path: string | null;
git_token: string | null;
git_user_name: string | null;
@@ -42,7 +45,7 @@ export type ProjectStatus =
| "stopping"
| "error";
export type AuthMode = "anthropic" | "bedrock";
export type Backend = "anthropic" | "bedrock" | "ollama" | "lite_llm";
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
@@ -58,6 +61,17 @@ export interface BedrockConfig {
disable_prompt_caching: boolean;
}
export interface OllamaConfig {
base_url: string;
model_id: string | null;
}
export interface LiteLlmConfig {
base_url: string;
api_key: string | null;
model_id: string | null;
}
export interface ContainerInfo {
container_id: string;
project_id: string;
@@ -77,6 +91,7 @@ export interface TerminalSession {
id: string;
projectId: string;
projectName: string;
sessionType: "claude" | "bash";
}
export type ImageSource = "registry" | "local_build" | "custom";
@@ -100,6 +115,8 @@ export interface AppSettings {
auto_check_updates: boolean;
dismissed_update_version: string | null;
timezone: string | null;
default_microphone: string | null;
dismissed_image_digest: string | null;
}
export interface UpdateInfo {
@@ -117,6 +134,12 @@ export interface ReleaseAsset {
size: number;
}
export interface ImageUpdateInfo {
remote_digest: string;
local_digest: string | null;
remote_updated_at: string | null;
}
export type McpTransportType = "stdio" | "http";
export interface McpServer {
@@ -133,3 +156,12 @@ export interface McpServer {
created_at: string;
updated_at: string;
}
export interface FileEntry {
name: string;
path: string;
is_directory: boolean;
size: number;
modified: string;
permissions: string;
}

View File

@@ -1,5 +1,5 @@
import { create } from "zustand";
import type { Project, TerminalSession, AppSettings, UpdateInfo, McpServer } from "../lib/types";
import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer } from "../lib/types";
interface AppState {
// Projects
@@ -39,6 +39,10 @@ interface AppState {
setUpdateInfo: (info: UpdateInfo | null) => void;
appVersion: string;
setAppVersion: (version: string) => void;
// Image update info
imageUpdateInfo: ImageUpdateInfo | null;
setImageUpdateInfo: (info: ImageUpdateInfo | null) => void;
}
export const useAppState = create<AppState>((set) => ({
@@ -111,4 +115,8 @@ export const useAppState = create<AppState>((set) => ({
setUpdateInfo: (info) => set({ updateInfo: info }),
appVersion: "",
setAppVersion: (version) => set({ appVersion: version }),
// Image update info
imageUpdateInfo: null,
setImageUpdateInfo: (info) => set({ imageUpdateInfo: info }),
}));

View File

@@ -101,6 +101,27 @@ WORKDIR /workspace
# ── Switch back to root for entrypoint (handles UID/GID remapping) ─────────
USER root
# ── OSC 52 clipboard support ─────────────────────────────────────────────
# Provides xclip/xsel/pbcopy shims that emit OSC 52 escape sequences,
# allowing programs inside the container to copy to the host clipboard.
COPY osc52-clipboard /usr/local/bin/osc52-clipboard
RUN chmod +x /usr/local/bin/osc52-clipboard \
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/xclip \
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/xsel \
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/pbcopy
# ── Audio capture shim (voice mode) ────────────────────────────────────────
# Provides fake rec/arecord that read PCM from a FIFO instead of a real mic,
# allowing Claude Code voice mode to work inside the container.
COPY audio-shim /usr/local/bin/audio-shim
RUN chmod +x /usr/local/bin/audio-shim \
&& ln -sf /usr/local/bin/audio-shim /usr/local/bin/rec \
&& ln -sf /usr/local/bin/audio-shim /usr/local/bin/arecord
COPY triple-c-sso-refresh /usr/local/bin/triple-c-sso-refresh
RUN chmod +x /usr/local/bin/triple-c-sso-refresh
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler

16
container/audio-shim Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
# Audio capture shim for Triple-C voice mode.
# Claude Code spawns `rec` or `arecord` to capture mic audio.
# Inside Docker there is no mic, so this shim reads PCM data from a
# FIFO that the Tauri host app writes to, and outputs it on stdout.
FIFO=/tmp/triple-c-audio-input
# Create the FIFO if it doesn't already exist
[ -p "$FIFO" ] || mkfifo "$FIFO" 2>/dev/null
# Clean exit on SIGTERM (Claude Code sends this when recording stops)
trap 'exit 0' TERM INT
# Stream PCM from the FIFO to stdout until we get a signal or EOF
cat "$FIFO"

View File

@@ -73,6 +73,44 @@ su -s /bin/bash claude -c '
sort -u -o /home/claude/.ssh/known_hosts /home/claude/.ssh/known_hosts
'
# ── AWS config setup ──────────────────────────────────────────────────────────
# Host AWS dir is mounted read-only at /tmp/.host-aws.
# Copy to /home/claude/.aws so AWS CLI can write to sso/cache and cli/cache.
if [ -d /tmp/.host-aws ]; then
rm -rf /home/claude/.aws
cp -a /tmp/.host-aws /home/claude/.aws
chown -R claude:claude /home/claude/.aws
chmod 700 /home/claude/.aws
# Ensure writable cache directories exist
mkdir -p /home/claude/.aws/sso/cache /home/claude/.aws/cli/cache
chown -R claude:claude /home/claude/.aws/sso /home/claude/.aws/cli
# Inline sso_session properties into profile sections so AWS SDKs that don't
# support the sso_session indirection format can resolve sso_region, etc.
if [ -f /home/claude/.aws/config ]; then
python3 -c '
import configparser, sys
c = configparser.ConfigParser()
c.read(sys.argv[1])
for sec in c.sections():
if not sec.startswith("profile ") and sec != "default":
continue
session = c.get(sec, "sso_session", fallback=None)
if not session or c.has_option(sec, "sso_start_url"):
continue
ss = f"sso-session {session}"
if not c.has_section(ss):
continue
for key in ("sso_start_url", "sso_region", "sso_registration_scopes"):
val = c.get(ss, key, fallback=None)
if val:
c.set(sec, key, val)
with open(sys.argv[1], "w") as f:
c.write(f)
' /home/claude/.aws/config 2>/dev/null || true
fi
fi
# ── Git credential helper (for HTTPS token) ─────────────────────────────────
if [ -n "$GIT_TOKEN" ]; then
CRED_FILE="/home/claude/.git-credentials"
@@ -103,6 +141,33 @@ if [ -n "$CLAUDE_INSTRUCTIONS" ]; then
unset CLAUDE_INSTRUCTIONS
fi
# ── Mission Control setup ───────────────────────────────────────────────────
if [ "$MISSION_CONTROL_ENABLED" = "1" ]; then
MC_HOME="/home/claude/mission-control"
MC_LINK="/workspace/mission-control"
if [ ! -d "$MC_HOME/.git" ]; then
echo "entrypoint: cloning mission-control..."
su -s /bin/bash claude -c \
'git clone https://github.com/msieurthenardier/mission-control.git /home/claude/mission-control' \
|| echo "entrypoint: warning — failed to clone mission-control"
else
echo "entrypoint: mission-control already present, skipping clone"
fi
# Symlink into workspace so Claude sees it at /workspace/mission-control
ln -sfn "$MC_HOME" "$MC_LINK"
chown -h claude:claude "$MC_LINK"
# Install skills to ~/.claude/skills/ so Claude Code discovers them automatically
if [ -d "$MC_HOME/.claude/skills" ]; then
mkdir -p /home/claude/.claude/skills
cp -r "$MC_HOME/.claude/skills/"* /home/claude/.claude/skills/ 2>/dev/null
chown -R claude:claude /home/claude/.claude/skills
echo "entrypoint: mission-control skills installed to ~/.claude/skills/"
fi
unset MISSION_CONTROL_ENABLED
fi
# ── MCP server configuration ────────────────────────────────────────────────
# Merge MCP server config into ~/.claude.json (preserves existing keys like
# OAuth tokens). Creates the file if it doesn't exist.
@@ -124,6 +189,24 @@ if [ -n "$MCP_SERVERS_JSON" ]; then
unset MCP_SERVERS_JSON
fi
# ── AWS SSO auth refresh command ──────────────────────────────────────────────
# When set, inject awsAuthRefresh into ~/.claude.json so Claude Code calls
# triple-c-sso-refresh when AWS credentials expire mid-session.
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
CLAUDE_JSON="/home/claude/.claude.json"
if [ -f "$CLAUDE_JSON" ]; then
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
if [ -n "$MERGED" ]; then
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
fi
else
printf '{"awsAuthRefresh":"%s"}\n' "$AWS_SSO_AUTH_REFRESH_CMD" > "$CLAUDE_JSON"
fi
chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON"
unset AWS_SSO_AUTH_REFRESH_CMD
fi
# ── Docker socket permissions ────────────────────────────────────────────────
if [ -S /var/run/docker.sock ]; then
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)

26
container/osc52-clipboard Normal file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
# OSC 52 clipboard provider — sends clipboard data to the host system clipboard
# via OSC 52 terminal escape sequences. Installed as xclip/xsel/pbcopy so that
# programs inside the container (e.g. Claude Code) can copy to clipboard.
#
# Supports common invocations:
# echo "text" | xclip -selection clipboard
# echo "text" | xsel --clipboard --input
# echo "text" | pbcopy
#
# Paste/output requests exit silently (not supported via OSC 52).
# Detect paste/output mode — exit silently since we can't read the host clipboard
for arg in "$@"; do
case "$arg" in
-o|--output) exit 0 ;;
esac
done
# Read all input from stdin
data=$(cat)
[ -z "$data" ] && exit 0
# Base64 encode and write OSC 52 escape sequence to the controlling terminal
encoded=$(printf '%s' "$data" | base64 | tr -d '\n')
printf '\033]52;c;%s\a' "$encoded" > /dev/tty 2>/dev/null

33
container/triple-c-sso-refresh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# Signal Triple-C to perform host-side AWS SSO login, then sync the result.
CACHE_DIR="$HOME/.aws/sso/cache"
HOST_CACHE="/tmp/.host-aws/sso/cache"
MARKER="/tmp/.sso-refresh-marker"
touch "$MARKER"
# Emit marker for Triple-C app to detect in terminal output
echo "###TRIPLE_C_SSO_REFRESH###"
echo "Waiting for SSO login to complete on host..."
TIMEOUT=120
ELAPSED=0
while [ $ELAPSED -lt $TIMEOUT ]; do
if [ -d "$HOST_CACHE" ]; then
NEW=$(find "$HOST_CACHE" -name "*.json" -newer "$MARKER" 2>/dev/null | head -1)
if [ -n "$NEW" ]; then
mkdir -p "$CACHE_DIR"
cp -f "$HOST_CACHE"/*.json "$CACHE_DIR/" 2>/dev/null
chown -R "$(whoami)" "$CACHE_DIR"
echo "AWS SSO credentials refreshed successfully."
rm -f "$MARKER"
exit 0
fi
fi
sleep 2
ELAPSED=$((ELAPSED + 2))
done
echo "SSO refresh timed out (${TIMEOUT}s). Please try again."
rm -f "$MARKER"
exit 1