Compare commits

..

26 Commits

Author SHA1 Message Date
jknapp dc253e8da0 Merge pull request 'Fix backend-switch AWS auth + add /workspace backup and terminal file drag-and-drop' (#8) from fix/backend-switch-aws-creds into main
Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 34s
Build App / build-macos (push) Successful in 2m23s
Build App / build-windows (push) Successful in 3m14s
Build App / build-linux (push) Successful in 6m22s
Build App / create-tag (push) Successful in 5s
Build App / sync-to-github (push) Successful in 10s
2026-06-30 22:20:00 +00:00
shadow-test 3e2e3f231b Third review pass: fix tar TOCTOU + transient backup status
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 31s
Build App / build-macos (pull_request) Successful in 2m16s
Build App / build-windows (pull_request) Successful in 3m9s
Build App / build-linux (pull_request) Successful in 5m53s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
- F2: upload_host_file_to_container now reads the dropped file into a Vec
  inside the blocking task and sizes the tar entry from those exact bytes,
  rather than stat-then-stream where a file changing size between the
  stat and the read could desync the tar header and silently corrupt the
  archive. Still runs off the async worker; memory stays bounded by the
  256 MiB drop cap.
- F4: the "Backup saved" confirmation now auto-clears after 8s (guarded
  against clobbering a newer status message) instead of lingering in the
  project card's status line indefinitely.

F1 (claimed AWS CLI regression from empty-env neutralization) was a false
positive: verified against aws-cli 2.35 that an empty AWS_ACCESS_KEY_ID is
treated as absent and botocore falls through to ~/.aws/credentials (the
call reached AWS and returned InvalidClientTokenId for the file's key, not
PartialCredentialsError). No change needed.

cargo check / tsc / vitest all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:16:42 -07:00
shadow-test 01a8f5c503 Apply remaining review findings (L-e, L-f)
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 1m30s
Build App / build-macos (pull_request) Successful in 2m16s
Build App / build-windows (pull_request) Successful in 3m6s
Build App / build-linux (pull_request) Successful in 6m11s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
- L-e: route terminal file drops purely by a bounds hit-test instead of
  the `active` flag. Inactive panes are display:none (zero-size rect) so
  they never match; a zero-size guard makes that explicit. Correct for
  the current tabbed layout and future-proof for split panes, where a
  drop on a visible-but-unfocused pane previously matched no handler.
- L-f: stream the dropped file straight into the upload tar inside a
  blocking task (new exec::upload_host_file_to_container) instead of
  reading the whole file into a Vec and then re-packing it. Peak memory
  drops from ~2x to ~1x the file size, and the synchronous file IO no
  longer runs on the async worker.

cargo check / tsc / vitest all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:05:25 -07:00
shadow-test 0945e21eb1 Address second review pass: fix start-time race + minor cleanups
Build App / compute-version (pull_request) Successful in 9s
Build Container / build-container (pull_request) Successful in 1m48s
Build App / build-macos (pull_request) Successful in 2m16s
Build App / build-windows (pull_request) Successful in 3m11s
Build App / build-linux (pull_request) Successful in 4m51s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
- M1 (race): don't mount the host AWS dir for static-credential Bedrock.
  sync_bedrock_credentials() is the sole writer of ~/.aws/credentials in
  that mode, and mounting /tmp/.host-aws let the entrypoint's
  `rm -rf ~/.aws; cp -a` race that write at startup (only when a global
  aws_config_path was also set). Static keys + AWS_REGION env are
  self-sufficient and don't need the host config, so skipping the mount
  removes the dual-writer entirely.
- L-a: exit codes are now read via wait_for_exec_exit(), which polls
  inspect_exec until the exec reports finished, so a non-zero tar/cred
  exit isn't missed by reading exit_code too early. The backup only fails
  on a definitively non-zero code (falls back to the empty-output check
  if undeterminable).
- L-b: fixed two comments that referenced the old
  write_bedrock_static_credentials name (now sync_bedrock_credentials).
- L-c: entrypoint only rewrites ~/.claude.json when awsAuthRefresh is
  actually present, avoiding a needless jq reformat on every non-SSO
  start.
- L-d: backup script traps EXIT to remove its mktemp staging dir even
  when tar fails, so failed backups don't accumulate temp dirs (with the
  sanitized config copy) in the container.

L-e (drop routing) is a non-issue: the layout is tabbed, so only one
terminal pane is ever visible; the active-guard routing is correct.

Verified the race fix, trap cleanup, grep guard, and exit-code polling.
cargo check / tsc / vitest all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:59:48 -07:00
shadow-test d65872dc94 Address remaining review items: L4/M1 + L2/L5 cleanup
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 57s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 2m54s
Build App / build-linux (pull_request) Successful in 5m44s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
- L4: sync_bedrock_credentials (renamed from write_bedrock_static_
  credentials) now also clears a stale ~/.aws/credentials when the
  project no longer uses static-credential Bedrock, so static keys don't
  linger unused in the persistent home volume after switching backends.
  Skipped when /tmp/.host-aws is mounted (host-managed ~/.aws). HOME is
  also set explicitly on the exec env for robustness.
- M1: the Backup button now has a tooltip and the success toast notes
  that the archive includes MCP/config which may contain MCP-embedded
  API keys (OAuth tokens are excluded) — keep it private.
- L2: backup now uses async file IO (tokio::fs::File + AsyncWriteExt,
  tokio::fs::remove_file) instead of blocking std::fs between awaits;
  dropped-file reads use tokio::fs::metadata/read.
- L5: upload_host_file_to_terminal explicitly `mkdir -p`s
  /tmp/triple-c-drops instead of relying on Docker's tar extractor to
  create the parent dir.

Verified L4 cleanup guard, L5 mkdir, async IO, and exit-code paths
against real containers. cargo check / tsc / vitest all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:37:33 -07:00
shadow-test edf0698774 Address PR review: backup correctness/security + drop hardening
Build App / compute-version (pull_request) Successful in 8s
Build Container / build-container (pull_request) Successful in 51s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 2m52s
Build App / build-linux (pull_request) Successful in 6m5s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Fixes from the code review of this branch:

- Backup requires a running container (it runs via `docker exec`, which
  can't run on a stopped one). Removed the misleading "Backup" button
  from the stopped-project actions, added an explicit running check with
  a clear error, and corrected the doc comment. (H1)
- jq sanitization fallback no longer leaks secrets: if ~/.claude.json
  can't be parsed, the backup substitutes an empty object and warns to
  stderr instead of copying the raw file (which held primaryApiKey /
  oauthAccount). Verified the raw key never reaches the archive. (H2)
- Dropped-file paths typed into the terminal are now always single-quoted
  (with '\'' escaping), not only when they contain whitespace — a name
  like `foo$(whoami).txt` was previously sent raw into the shell. (M2)
- write_bedrock_static_credentials checks the exec exit code via the new
  exec_oneshot_env_status and fails loudly on a write/chmod error instead
  of silently reporting success. exec_oneshot keeps its
  ignore-exit-code behavior so list_container_files is unaffected. (M4)
- Backup removes a partial/truncated archive on any stream error and
  treats a non-zero tar exit code as failure (a truncated gzip was
  previously reported as success). (L1)
- Dropped files are capped at 256 MiB to avoid ballooning host RAM
  (the file is read fully into memory then re-tarred). (M3)
- Stopped excluding .git/objects from the backup so git history,
  including unpushed commits, is preserved faithfully. (L3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:28:00 -07:00
shadow-test 84e0bdf7b4 Add file drag-and-drop onto the terminal
Build App / compute-version (pull_request) Successful in 6s
Build Container / build-container (pull_request) Successful in 1m25s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-windows (pull_request) Successful in 4m14s
Build App / build-linux (pull_request) Successful in 4m54s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Drop files onto a terminal pane and they're copied into the container and
their in-container paths typed into the prompt, so Claude Code can read
them for reference — mirroring the existing image-paste flow.

Backend: upload_host_file_to_terminal reads the dropped host file and
writes it under /tmp/triple-c-drops/<name> in the session's container,
returning that path. Rejects directories and unreadable paths.

Frontend: TerminalView subscribes to Tauri's webview onDragDropEvent
(OS file drops are intercepted at the webview level, so HTML5 ondrop
wouldn't expose paths). The window-wide event is guarded by the pane's
`active` flag plus a bounds hit-test so a drop only affects the terminal
it landed on; multiple files are uploaded and their paths inserted
space-separated (quoted when they contain spaces).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:11:09 -07:00
shadow-test 10e689eaa6 Include sanitized home config in project backup
Extends download_container_backup to also capture the container's home
config so MCP servers, settings, and skills set up directly via Claude
Code (stored in ~/.claude.json / ~/.claude, on the home/config volumes
that a Reset wipes) survive a backup/restore cycle.

Secrets are stripped per the "exclude secrets" choice: ~/.claude.json is
filtered through jq to drop primaryApiKey/oauthAccount/customApiKeyResponses
(mcpServers and settings are kept), and ~/.claude/.credentials.json (the
OAuth tokens) is omitted. Staged config is archived under home-claude/ in
the tarball. Verified on the Ubuntu/jq container base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:04:19 -07:00
shadow-test d07dcdfea9 Add "Backup" action to download a project's /workspace as .tar.gz
Adds a manual backup button on each project card (next to Start/Reset
when stopped, and next to Files when running) that saves a gzipped
tarball of the container's /workspace to a host path via the native save
dialog.

Backend: download_container_backup runs `tar czf -` inside the container
(so excludes + compression happen there rather than streaming a 16 GB
workspace) and pipes stdout straight to the chosen file. Regenerable
build artifacts (node_modules, target, .git/objects) are excluded so the
archive stays restore-sized. Returns bytes written; stderr is captured
for error reporting and a zero-byte result is treated as failure.

Works whether the container is running or stopped (only requires that it
exists). Verified on the Ubuntu/GNU-tar container base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:48:04 -07:00
shadow-test 424ab04ca8 Fix stale AWS/Bedrock auth carrying over on backend switch
When a project switched backends (e.g. Bedrock -> Anthropic), the
recreated container kept authenticating against Bedrock, and SSO kept
firing after switching away. Three root causes, all fixed:

1. Recreation builds the new container from a `docker commit` snapshot.
   commit always bakes the previous container's full ENV into the image
   (an empty commit Config does NOT strip it, and the commit API cannot
   remove env). So CLAUDE_CODE_USE_BEDROCK=1 / AWS_* survived into the
   new container. Fix: create_container now explicitly clears every
   managed auth key the active backend does not set (MANAGED_AUTH_KEYS),
   so create-time env overrides the stale baked-in values.

2. awsAuthRefresh was written into ~/.claude.json (persisted home
   volume) and never removed, so Claude Code kept invoking
   triple-c-sso-refresh after switching to a non-SSO backend. Fix:
   entrypoint now deletes awsAuthRefresh when AWS_SSO_AUTH_REFRESH_CMD
   is unset, idempotent both ways.

3. Static/session AWS creds were baked into Config.Env at create time,
   so a stop/start kept stale creds and rotated keys never refreshed
   without a full recreation. Fix: static creds are no longer injected
   as env vars; write_bedrock_static_credentials() writes
   ~/.aws/credentials (0600, secrets via exec env not argv) on every
   start, and removes a stale ~/.aws/config left from a prior profile/SSO
   session. Static creds also dropped from the bedrock fingerprint so a
   key rotation refreshes in place instead of forcing recreation.

Adds exec_oneshot_env() for env-carrying one-shot execs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:34:43 -07:00
jknapp 1716fb5c82 Merge pull request 'Fix xterm clipping; move mic + Jump to Current into status bar' (#7) from terminal-layout-statusbar into main
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m20s
Build App / build-windows (push) Successful in 4m39s
Build App / build-linux (push) Successful in 4m58s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s
Reviewed-on: #7
2026-06-29 15:23:35 +00:00
shadow-test da7b7b9bd5 Address review: pin STT transcript, clear stale scroll state
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 3m49s
Build App / build-linux (pull_request) Successful in 4m48s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Follow-up to PR review on terminal-layout-statusbar:

- [Major] Pin STT transcripts to the originating terminal. The single
  useSTT instance is bound to the live active session, which can change
  mid-recording. Capture the session id at recording start in a ref and
  inject the transcript there instead of the live sessionId, so text
  always lands in the terminal where recording began.
- [Minor] Clear the status-bar scroll state when the active terminal
  unmounts, and null out termRef on dispose, so scrollActiveToBottom
  can't point at a disposed terminal. Tab switches don't unmount, so
  this only fires when the active session is actually closed.
- [Nit] Fix the terminal padding comment to match the symmetric value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:33:27 -07:00
shadow-test 3d2d979197 Fix xterm clipping; move mic + Jump to Current into status bar
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m19s
Build App / build-linux (pull_request) Successful in 5m3s
Build App / build-windows (pull_request) Successful in 7m34s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Terminal layout fixes for the xterm pane:

- Stop the terminal grid from clipping its rightmost column / bottom
  row. The padding was on the element xterm mounts into, which the
  FitAddon measures; the grid overhang got clipped. Padding now lives on
  a wrapper and the xterm host fills it with no padding.
- Move the STT mic from a floating bottom-left overlay into the status
  bar (far right). A single useSTT instance bound to the active session
  now lives in App; Ctrl+Shift+M routes through the store.
- Move "Jump to Current" from a floating terminal overlay into the
  status bar. The active TerminalView surfaces its scroll state and
  scroll action via the store.
- Tighten terminal padding (was 8/12/48/16) now that nothing floats over
  it, so the terminal claims as much area as possible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:26:05 -07:00
jknapp de2752557d Merge pull request 'Fix Windows release upload: idempotent get-or-create + fail-loud' (#6) from feature/ux-improvements into main
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m39s
Build App / build-windows (push) Successful in 3m56s
Build App / build-linux (push) Successful in 5m51s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 13s
Reviewed-on: #6
2026-06-24 16:28:58 +00:00
jknapp 9221c25474 Merge branch 'main' into feature/ux-improvements
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m35s
Build App / build-windows (pull_request) Successful in 3m51s
Build App / build-linux (pull_request) Successful in 5m20s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
2026-06-24 16:28:45 +00:00
shadow-test 997e1ab3a9 Fix Windows release upload: idempotent get-or-create + fail-loud
Build App / compute-version (pull_request) Successful in 9s
Build App / build-macos (pull_request) Successful in 2m36s
Build App / build-windows (pull_request) Successful in 2m50s
Build App / build-linux (pull_request) Successful in 6m26s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
The cmd-batch upload step POSTed to /releases unconditionally. On a
re-run the v{VERSION}-win tag already exists, so Gitea returns 409, the
findstr id parse yields an empty RELEASE_ID, and uploads go to a
malformed .../releases//assets URL -- all silently swallowed by cmd and
`curl -s`, so the step reported success while attaching no assets.

Rewrite in PowerShell mirroring the macOS job: look the release up by
tag first and create only on 404, throw if the id can't be resolved,
delete same-named assets left over from partial runs before re-upload,
and fail loudly (ErrorActionPreference=Stop, curl.exe -fsS with retries,
$LASTEXITCODE check).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:22:55 -07:00
jknapp 2be6b9d9a8 Merge pull request 'UX improvements: Claude auto-update on container start + file-list scroll' (#5) from feature/ux-improvements into main
Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 1m3s
Build App / build-linux (push) Successful in 4m58s
Build App / build-macos (push) Successful in 2m19s
Build App / build-windows (push) Failing after 18m35s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
Reviewed-on: #5
2026-06-24 01:51:49 +00:00
jknapp ebfe1f11a6 Merge branch 'main' into feature/ux-improvements
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 30s
Build App / build-linux (pull_request) Successful in 5m1s
Build App / build-windows (pull_request) Failing after 14m55s
Build App / build-macos (pull_request) Successful in 2m14s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
2026-06-24 01:50:36 +00:00
shadow-test 7f8102985e Update Claude on container start; harden file-list scroll
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 7m34s
Build App / build-linux (pull_request) Successful in 5m2s
Build App / build-windows (pull_request) Failing after 16m56s
Build App / build-macos (pull_request) Successful in 2m37s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Add a time-bounded `claude update` to entrypoint.sh that runs as the
claude user before the container is marked ready, so every terminal
session launches the latest CLI. Non-fatal and capped at 120s so an
offline/slow network never blocks container readiness; PATH covers both
~/.claude/bin and ~/.local/bin install locations.

Add flex-shrink-0 to the FileManagerModal header/footer so a long file
list can't squeeze them and the scroll region stays robust.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:24:37 -07:00
jknapp 1a5fbd6be4 Merge pull request 'UX: collapsible sidebar, settings accordion, global backend defaults, tab rename' (#4) from feature/ux-improvements into main
Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 1m1s
Build App / build-macos (push) Successful in 2m30s
Build App / build-windows (push) Successful in 2m50s
Build App / build-linux (push) Successful in 4m47s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
Reviewed-on: #4
2026-05-24 16:39:34 +00:00
shadow-test 2fa6abeae0 Allow renaming terminal tabs (persisted per project)
Build App / compute-version (pull_request) Successful in 3s
Build App / build-windows (pull_request) Successful in 5m33s
Build Container / build-container (pull_request) Successful in 7m58s
Build App / build-linux (pull_request) Successful in 4m51s
Build App / build-macos (pull_request) Successful in 2m39s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Right-click a tab (or double-click) to rename. Renamed labels show
as "ProjectName: CustomName" and are stored in the project's
renamed_session_names map. The entry is cleared on tab close.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:13:25 -07:00
47 changed files with 2039 additions and 856 deletions
+104 -20
View File
@@ -264,25 +264,67 @@ jobs:
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="v${{ needs.compute-version.outputs.version }}-mac"
# Create release
curl -s -X POST \
# Idempotent get-or-create. macOS upload has historically failed
# mid-stream (curl exit 92, exit 28), leaving the release record
# with empty assets. A naive POST /releases on the next run hits
# 409 from Gitea for the duplicate tag, the JSON parse below
# then yields an empty RELEASE_ID, and pipefail aborts with an
# opaque exit 1. Look the release up by tag first; create only
# if it doesn't exist; reuse the existing id otherwise.
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
case "${HTTP_CODE}" in
200)
echo "Release ${TAG} already exists, reusing"
;;
404)
echo "Release ${TAG} not found, creating"
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
;;
*)
echo "Unexpected HTTP ${HTTP_CODE} from get-release-by-tag" >&2
cat release.json >&2 || true
exit 1
;;
esac
RELEASE_ID=$(grep -o '"id":[0-9]*' release.json | head -1 | grep -o '[0-9]*' || true)
if [ -z "${RELEASE_ID}" ]; then
echo "Failed to parse release id; response was:" >&2
cat release.json >&2
exit 1
fi
echo "Release ID: ${RELEASE_ID}"
# Upload each artifact.
# Note: the macOS runner has historically dropped this upload mid-stream
# (curl exit 92 — HTTP/2 stream not closed cleanly), leaving the release
# with empty assets. The flags below force HTTP/1.1, fail loudly on HTTP
# errors, and retry transient failures. Linux and Windows uploads remain
# on the original simpler form because they have not exhibited this
# flake. If they ever do, mirror this block over.
# Upload each artifact. If an asset with the same name already
# exists on the release (left over from a partial prior run),
# delete it first so the upload is replace-not-conflict.
# Network hardening: HTTP/1.1 to dodge HTTP/2 stream flakes
# the macOS runner has hit, retries with backoff for transient
# drops, and -f so HTTP errors stop being silently swallowed.
for file in artifacts/*; do
[ -f "$file" ] || continue
filename=$(basename "$file")
EXISTING_ID=$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
if [ -n "${EXISTING_ID}" ]; then
echo "Deleting existing asset ${filename} (id ${EXISTING_ID})"
curl -fsS -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
fi
echo "Uploading ${filename}..."
curl -fsS --http1.1 \
--retry 5 --retry-all-errors --retry-delay 5 \
@@ -386,20 +428,62 @@ jobs:
- name: Upload to Gitea release
if: gitea.event_name == 'push'
shell: powershell
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
COMMIT_SHA: ${{ gitea.sha }}
VERSION: ${{ needs.compute-version.outputs.version }}
run: |
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${{ 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%
for %%f in (artifacts\*) do (
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"
)
$ErrorActionPreference = "Stop"
$tag = "v$env:VERSION-win"
$headers = @{ Authorization = "token $env:TOKEN" }
$api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
# Idempotent get-or-create. The old cmd-batch version swallowed
# curl errors and parsed the release id with findstr, so a 409 on
# a pre-existing tag yielded an empty RELEASE_ID and uploads went to
# a malformed .../releases//assets URL while the step still reported
# success. Look the release up by tag first; create only on 404.
try {
$release = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/tags/$tag"
Write-Host "Release $tag already exists, reusing"
} catch {
if ($_.Exception.Response.StatusCode.value__ -eq 404) {
Write-Host "Release $tag not found, creating"
$body = @{
tag_name = $tag
name = "Triple-C v$env:VERSION (Windows)"
body = "Automated build from commit $env:COMMIT_SHA"
} | ConvertTo-Json
$release = Invoke-RestMethod -Method Post -Headers $headers `
-ContentType "application/json" -Body $body -Uri "$api/releases"
} else {
throw
}
}
$releaseId = $release.id
if (-not $releaseId) { throw "Failed to resolve release id for $tag" }
Write-Host "Release ID: $releaseId"
# Upload each artifact. Delete any same-named asset left over from a
# partial prior run first, so the upload replaces rather than 409s.
$existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$releaseId/assets"
foreach ($file in Get-ChildItem -File -Path artifacts\*) {
$name = $file.Name
$dupe = $existing | Where-Object { $_.name -eq $name }
if ($dupe) {
Write-Host "Deleting existing asset $name (id $($dupe.id))"
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$releaseId/assets/$($dupe.id)" | Out-Null
}
Write-Host "Uploading $name..."
$uploadUri = "$api/releases/$releaseId/assets?name=$([uri]::EscapeDataString($name))"
curl.exe -fsS --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 `
-X POST -H "Authorization: token $env:TOKEN" `
-H "Content-Type: application/octet-stream" `
--data-binary "@$($file.FullName)" $uploadUri
if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" }
}
create-tag:
runs-on: ubuntu-latest
+183 -1
View File
@@ -1,4 +1,5 @@
use bollard::container::{DownloadFromContainerOptions, UploadToContainerOptions};
use bollard::container::{DownloadFromContainerOptions, LogOutput, UploadToContainerOptions};
use bollard::exec::{CreateExecOptions, StartExecResults};
use futures_util::StreamExt;
use serde::Serialize;
use tauri::State;
@@ -151,6 +152,187 @@ pub async fn download_container_file(
Ok(())
}
/// Create a `.tar.gz` backup of the container and stream it to a host file.
/// The archive contains:
/// - the workspace (default /workspace), minus regenerable build artifacts
/// (node_modules, target), at the archive root, and
/// - a sanitized copy of the home config under `home-claude/`: ~/.claude.json
/// with secret-bearing keys removed (mcpServers/settings kept) and ~/.claude/
/// minus the OAuth `.credentials.json`, so MCP servers, settings and skills
/// set up via Claude Code survive a Reset.
/// `.git` is kept in full so the backup faithfully preserves git history,
/// including unpushed commits. Build + gzip happen inside the container so a
/// large workspace isn't streamed in full. The container must be RUNNING (the
/// backup runs via `docker exec`). Returns the number of bytes written.
#[tauri::command]
pub async fn download_container_backup(
project_id: String,
host_path: String,
container_path: Option<String>,
state: State<'_, AppState>,
) -> Result<u64, 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(|| "No container exists for this project yet — start it first".to_string())?;
let docker = get_docker()?;
// The backup runs inside the container via `docker exec`, which requires it
// to be running. Fail with a clear message rather than a raw Docker error.
let running = docker
.inspect_container(container_id, None)
.await
.ok()
.and_then(|info| info.state)
.and_then(|s| s.running)
.unwrap_or(false);
if !running {
return Err("Start the project before backing up — the backup runs inside the running container.".to_string());
}
let path = container_path.unwrap_or_else(|| "/workspace".to_string());
// Stage a sanitized home config, then tar+gzip workspace + staged config to
// stdout. mktemp/jq output go nowhere near stdout, so the only thing the
// exec emits on stdout is the archive itself. --ignore-failed-read keeps a
// transient unreadable file from aborting the whole backup. If jq can't
// parse ~/.claude.json we substitute an empty object — never the raw file —
// so secrets can't leak through the sanitization fallback.
let script = r#"set -e
STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT
mkdir -p "$STAGE/home-claude"
if [ -f "$HOME/.claude.json" ]; then
if ! jq 'del(.primaryApiKey, .oauthAccount, .customApiKeyResponses)' "$HOME/.claude.json" \
> "$STAGE/home-claude/.claude.json" 2>/dev/null; then
echo "warning: could not sanitize .claude.json; omitting it from backup" >&2
printf '{}' > "$STAGE/home-claude/.claude.json"
fi
fi
if [ -d "$HOME/.claude" ]; then
cp -a "$HOME/.claude" "$STAGE/home-claude/.claude" 2>/dev/null || true
rm -f "$STAGE/home-claude/.claude/.credentials.json"
fi
tar czf - --ignore-failed-read \
--exclude='*/node_modules' --exclude='*/target' \
-C "$TC_BACKUP_SRC" . \
-C "$STAGE" home-claude"#;
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
let exec = docker
.create_exec(
container_id,
CreateExecOptions {
attach_stdout: Some(true),
attach_stderr: Some(true),
cmd: Some(cmd),
env: Some(vec![
"HOME=/home/claude".to_string(),
format!("TC_BACKUP_SRC={}", path),
]),
user: Some("claude".to_string()),
..Default::default()
},
)
.await
.map_err(|e| format!("Failed to create backup exec: {}", e))?;
let result = docker
.start_exec(&exec.id, None)
.await
.map_err(|e| format!("Failed to start backup exec: {}", e))?;
let mut output = match result {
StartExecResults::Attached { output, .. } => output,
StartExecResults::Detached => return Err("Backup exec started detached".to_string()),
};
use tokio::io::AsyncWriteExt;
let file = tokio::fs::File::create(&host_path)
.await
.map_err(|e| format!("Failed to create backup file: {}", e))?;
let mut writer = tokio::io::BufWriter::new(file);
let mut total: u64 = 0;
let mut stderr_text = String::new();
let mut stream_err: Option<String> = None;
while let Some(msg) = output.next().await {
match msg {
Ok(LogOutput::StdOut { message }) => {
if let Err(e) = writer.write_all(&message).await {
stream_err = Some(format!("Failed to write backup file: {}", e));
break;
}
total += message.len() as u64;
}
Ok(LogOutput::StdErr { message }) => {
stderr_text.push_str(&String::from_utf8_lossy(&message));
}
Ok(_) => {}
Err(e) => {
stream_err = Some(format!("Backup stream error: {}", e));
break;
}
}
}
if stream_err.is_none() {
if let Err(e) = writer.flush().await {
stream_err = Some(format!("Failed to finalize backup file: {}", e));
}
}
drop(writer);
// The tar pipeline can abort mid-stream (producing a truncated archive) and
// still have sent bytes, so a non-zero exit must be treated as failure even
// when `total > 0`. Poll until the exec actually reports finished so the
// exit code is reliably populated; if it can't be determined we fall back to
// the `total == 0` check below.
let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await;
if stream_err.is_none() && exit_code.is_some_and(|c| c != 0) {
stream_err = Some(format!(
"Backup command failed (exit {}){}",
exit_code.unwrap_or(-1),
if stderr_text.trim().is_empty() {
String::new()
} else {
format!(": {}", stderr_text.trim())
}
));
}
if stream_err.is_none() && total == 0 {
stream_err = Some(format!(
"Backup produced no data{}",
if stderr_text.trim().is_empty() {
String::new()
} else {
format!(": {}", stderr_text.trim())
}
));
}
if let Some(err) = stream_err {
// Don't leave a partial/corrupt archive behind.
let _ = tokio::fs::remove_file(&host_path).await;
return Err(err);
}
log::info!(
"Wrote {} byte backup for project {} to {}",
total,
project_id,
host_path
);
Ok(total)
}
#[tauri::command]
pub async fn upload_file_to_container(
project_id: String,
+22 -4
View File
@@ -193,16 +193,20 @@ pub async fn start_project_container(
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 ollama.base_url.is_empty()
&& settings.global_ollama.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
{
return Err("Ollama base URL is required. Set it per-project or in global Ollama settings.".to_string());
}
}
if project.backend == Backend::OpenAiCompatible {
let oai_config = project.openai_compatible_config.as_ref()
.ok_or_else(|| "OpenAI Compatible backend selected but no configuration found.".to_string())?;
if oai_config.base_url.is_empty() {
return Err("OpenAI Compatible base URL is required.".to_string());
if oai_config.base_url.is_empty()
&& settings.global_openai_compatible.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
{
return Err("OpenAI Compatible base URL is required. Set it per-project or in global settings.".to_string());
}
}
@@ -334,6 +338,9 @@ pub async fn start_project_container(
let needs_recreate = docker::container_needs_recreation(
&existing_id,
&project,
&settings.global_aws,
&settings.global_ollama,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
settings.timezone.as_deref(),
@@ -369,6 +376,8 @@ pub async fn start_project_container(
&create_image,
aws_config_path.as_deref(),
&settings.global_aws,
&settings.global_ollama,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
settings.timezone.as_deref(),
@@ -406,6 +415,8 @@ pub async fn start_project_container(
&create_image,
aws_config_path.as_deref(),
&settings.global_aws,
&settings.global_ollama,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
settings.timezone.as_deref(),
@@ -421,6 +432,13 @@ pub async fn start_project_container(
new_id
};
// Sync Bedrock credentials on every start: refresh static/session creds
// so rotated keys are picked up without a full container recreation, and
// clear stale creds when the project no longer uses static-cred Bedrock.
if let Err(e) = docker::sync_bedrock_credentials(&container_id, &project).await {
log::warn!("Failed to sync AWS credentials for project {}: {}", project.id, e);
}
Ok(container_id)
}.await;
@@ -183,6 +183,55 @@ pub async fn paste_image_to_terminal(
.await
}
/// Copy a host file (e.g. dragged onto the terminal) into the container so
/// Claude Code can read it, and return the in-container path. Mirrors the
/// image-paste flow: the file is placed under /tmp/triple-c-drops/ keeping its
/// original name. Returns an error for paths that aren't readable regular files
/// (e.g. a dropped directory).
#[tauri::command]
pub async fn upload_host_file_to_terminal(
session_id: String,
host_path: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let container_id = state.exec_manager.get_container_id(&session_id).await?;
let meta = tokio::fs::metadata(&host_path)
.await
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
if meta.is_dir() {
return Err(format!("{} is a directory — drop individual files", host_path));
}
// Guard against ballooning host RAM: the file is packed into an in-memory
// tar before upload, so cap the size of a dropped file.
const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024; // 256 MiB
if meta.len() > MAX_DROP_BYTES {
return Err(format!(
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project or use the Files panel instead.",
meta.len() as f64 / (1024.0 * 1024.0),
MAX_DROP_BYTES / (1024 * 1024)
));
}
let base = std::path::Path::new(&host_path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "dropped-file".to_string());
// Ensure the destination directory exists rather than relying on Docker's
// archive extractor to create the parent for the uploaded tar entry.
crate::docker::exec::exec_oneshot(
&container_id,
vec!["mkdir".to_string(), "-p".to_string(), "/tmp/triple-c-drops".to_string()],
)
.await?;
let file_name = format!("triple-c-drops/{}", base);
crate::docker::exec::upload_host_file_to_container(&container_id, &host_path, &file_name).await
}
#[tauri::command]
pub async fn start_audio_bridge(
session_id: String,
+258 -37
View File
@@ -8,7 +8,7 @@ use std::collections::HashMap;
use sha2::{Sha256, Digest};
use super::client::get_docker;
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
@@ -256,18 +256,37 @@ fn sha256_hex(input: &str) -> String {
format!("{:x}", hasher.finalize())
}
/// Resolve a per-project string value with a global fallback. Returns `None`
/// when both are blank, otherwise the per-project value if set, else the global.
fn resolve_with_global<'a>(per_project: Option<&'a str>, global: Option<&'a str>) -> Option<&'a str> {
let project_val = per_project.map(str::trim).filter(|s| !s.is_empty());
if project_val.is_some() {
return project_val;
}
global.map(str::trim).filter(|s| !s.is_empty())
}
/// Compute a fingerprint for the Bedrock configuration so we can detect changes.
fn compute_bedrock_fingerprint(project: &Project) -> String {
/// Includes the resolved model_id (per-project blank → global default) so that
/// changing the global default forces a container recreation.
fn compute_bedrock_fingerprint(project: &Project, global_aws: &GlobalAwsSettings) -> String {
if let Some(ref bedrock) = project.bedrock_config {
let effective_model = resolve_with_global(
bedrock.model_id.as_deref(),
global_aws.default_model_id.as_deref(),
).unwrap_or("").to_string();
// NOTE: the static credential fields (access key / secret / session
// token) are intentionally NOT part of the fingerprint. They are
// written to ~/.aws/credentials on every start by
// sync_bedrock_credentials(), so a key rotation should refresh
// in place rather than force a full container recreation. Region,
// profile, and bearer token remain env-based and so stay here.
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(),
effective_model,
format!("{}", bedrock.disable_prompt_caching),
bedrock.service_tier.as_deref().unwrap_or("").to_string(),
];
@@ -278,12 +297,18 @@ fn compute_bedrock_fingerprint(project: &Project) -> String {
}
/// Compute a fingerprint for the Ollama configuration so we can detect changes.
fn compute_ollama_fingerprint(project: &Project) -> String {
/// Includes the resolved base_url and model_id (per-project blank → global default).
fn compute_ollama_fingerprint(project: &Project, global_ollama: &GlobalOllamaSettings) -> String {
if let Some(ref ollama) = project.ollama_config {
let parts = vec![
ollama.base_url.clone(),
ollama.model_id.as_deref().unwrap_or("").to_string(),
];
let effective_url = resolve_with_global(
Some(&ollama.base_url),
global_ollama.base_url.as_deref(),
).unwrap_or("").to_string();
let effective_model = resolve_with_global(
ollama.model_id.as_deref(),
global_ollama.default_model_id.as_deref(),
).unwrap_or("").to_string();
let parts = vec![effective_url, effective_model];
sha256_hex(&parts.join("|"))
} else {
String::new()
@@ -291,12 +316,24 @@ fn compute_ollama_fingerprint(project: &Project) -> String {
}
/// Compute a fingerprint for the OpenAI Compatible configuration so we can detect changes.
fn compute_openai_compatible_fingerprint(project: &Project) -> String {
/// Includes the resolved base_url and model_id (per-project blank → global default).
fn compute_openai_compatible_fingerprint(
project: &Project,
global_openai_compatible: &GlobalOpenAiCompatibleSettings,
) -> String {
if let Some(ref config) = project.openai_compatible_config {
let effective_url = resolve_with_global(
Some(&config.base_url),
global_openai_compatible.base_url.as_deref(),
).unwrap_or("").to_string();
let effective_model = resolve_with_global(
config.model_id.as_deref(),
global_openai_compatible.default_model_id.as_deref(),
).unwrap_or("").to_string();
let parts = vec![
config.base_url.clone(),
effective_url,
config.api_key.as_deref().unwrap_or("").to_string(),
config.model_id.as_deref().unwrap_or("").to_string(),
effective_model,
];
sha256_hex(&parts.join("|"))
} else {
@@ -552,6 +589,8 @@ pub async fn create_container(
image_name: &str,
aws_config_path: Option<&str>,
global_aws: &GlobalAwsSettings,
global_ollama: &GlobalOllamaSettings,
global_openai_compatible: &GlobalOpenAiCompatibleSettings,
global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar],
timezone: Option<&str>,
@@ -633,15 +672,14 @@ pub async fn create_container(
match bedrock.auth_method {
BedrockAuthMethod::StaticCredentials => {
if let Some(ref key_id) = bedrock.aws_access_key_id {
env_vars.push(format!("AWS_ACCESS_KEY_ID={}", key_id));
}
if let Some(ref secret) = bedrock.aws_secret_access_key {
env_vars.push(format!("AWS_SECRET_ACCESS_KEY={}", secret));
}
if let Some(ref token) = bedrock.aws_session_token {
env_vars.push(format!("AWS_SESSION_TOKEN={}", token));
}
// Static/session credentials are NOT injected as env vars.
// They are written to ~/.aws/credentials by
// sync_bedrock_credentials() on every container
// start, so rotated/updated keys are picked up without a
// full container recreation (and never get baked into the
// snapshot image). The empty values set by the
// MANAGED_AUTH_KEYS neutralization pass below are ignored by
// the AWS SDK, which falls through to the credentials file.
}
BedrockAuthMethod::Profile => {
// Per-project profile overrides global
@@ -659,7 +697,10 @@ pub async fn create_container(
}
}
if let Some(ref model) = bedrock.model_id {
if let Some(model) = resolve_with_global(
bedrock.model_id.as_deref(),
global_aws.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
}
@@ -679,9 +720,17 @@ 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));
if let Some(url) = resolve_with_global(
Some(&ollama.base_url),
global_ollama.base_url.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", url));
}
env_vars.push("ANTHROPIC_AUTH_TOKEN=ollama".to_string());
if let Some(ref model) = ollama.model_id {
if let Some(model) = resolve_with_global(
ollama.model_id.as_deref(),
global_ollama.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
}
}
@@ -690,16 +739,59 @@ pub async fn create_container(
// OpenAI Compatible configuration
if project.backend == Backend::OpenAiCompatible {
if let Some(ref config) = project.openai_compatible_config {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", config.base_url));
if let Some(url) = resolve_with_global(
Some(&config.base_url),
global_openai_compatible.base_url.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", url));
}
if let Some(ref key) = config.api_key {
env_vars.push(format!("ANTHROPIC_AUTH_TOKEN={}", key));
}
if let Some(ref model) = config.model_id {
if let Some(model) = resolve_with_global(
config.model_id.as_deref(),
global_openai_compatible.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
}
}
}
// ── Neutralize stale backend auth env vars ──────────────────────────────
// When a project switches backends (e.g. Bedrock → Anthropic) the container
// is recreated *from a snapshot image* committed off the previous container.
// `docker commit` always bakes the previous container's full ENV into that
// image, and the commit API cannot strip it. So any auth var set under the
// old backend (e.g. CLAUDE_CODE_USE_BEDROCK=1, AWS_*) survives in the image
// ENV and stays active unless we explicitly override it at create time.
// Create-time env takes precedence over image ENV, so we set every managed
// auth key the *current* backend did NOT set to an empty value, clearing the
// stale baked-in one.
const MANAGED_AUTH_KEYS: &[&str] = &[
"CLAUDE_CODE_USE_BEDROCK",
"AWS_REGION",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_PROFILE",
"AWS_BEARER_TOKEN_BEDROCK",
"AWS_SSO_AUTH_REFRESH_CMD",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_MODEL",
"DISABLE_PROMPT_CACHING",
"ANTHROPIC_BEDROCK_SERVICE_TIER",
];
let already_set: std::collections::HashSet<String> = env_vars
.iter()
.filter_map(|e| e.split('=').next().map(|k| k.to_string()))
.collect();
for key in MANAGED_AUTH_KEYS {
if !already_set.contains(*key) {
env_vars.push(format!("{}=", key));
}
}
// Custom environment variables (global + per-project, project overrides global for same key)
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
@@ -842,7 +934,19 @@ pub async fn create_container(
false
};
if should_mount_aws || aws_config_path.is_some() {
// For static-credential Bedrock, sync_bedrock_credentials() is the sole
// owner of ~/.aws/credentials (it rewrites it on every start). Mounting the
// host AWS dir would make the entrypoint's `rm -rf ~/.aws; cp -a` race that
// write at startup, so we never mount it in that case — the static keys
// (+ AWS_REGION env) are self-sufficient and don't need the host config.
let is_bedrock_static = project.backend == Backend::Bedrock
&& project
.bedrock_config
.as_ref()
.map(|b| b.auth_method == BedrockAuthMethod::StaticCredentials)
.unwrap_or(false);
if (should_mount_aws || aws_config_path.is_some()) && !is_bedrock_static {
let aws_dir = aws_config_path
.map(|p| std::path::PathBuf::from(p))
.or_else(|| dirs::home_dir().map(|h| h.join(".aws")));
@@ -904,9 +1008,9 @@ pub async fn create_container(
labels.insert("triple-c.project-name".to_string(), project.name.clone());
labels.insert("triple-c.backend".to_string(), format!("{:?}", project.backend));
labels.insert("triple-c.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.openai-compatible-fingerprint".to_string(), compute_openai_compatible_fingerprint(project));
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project, global_aws));
labels.insert("triple-c.ollama-fingerprint".to_string(), compute_ollama_fingerprint(project, global_ollama));
labels.insert("triple-c.openai-compatible-fingerprint".to_string(), compute_openai_compatible_fingerprint(project, global_openai_compatible));
labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
labels.insert("triple-c.image".to_string(), image_name.to_string());
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
@@ -1005,10 +1109,124 @@ pub fn get_snapshot_image_name(project: &Project) -> String {
format!("triple-c-snapshot-{}:latest", project.id)
}
/// Keep the container's `~/.aws/credentials` in sync with the project's Bedrock
/// auth on every container start:
/// - **Bedrock + static credentials**: (re)write `~/.aws/credentials` from the
/// latest keychain values and drop a stale `~/.aws/config` left by a prior
/// profile/SSO session, so rotated keys are picked up without recreating the
/// container.
/// - **Any other backend / auth method**: remove a stale `~/.aws/credentials`
/// written by a previous static-credential session, so the secrets don't
/// linger unused in the persistent home volume after switching away.
///
/// Both cleanups are skipped when `/tmp/.host-aws` is mounted (a global
/// `aws_config_path` is configured), since the entrypoint already refreshes
/// `~/.aws` from the host on every start in that case.
pub async fn sync_bedrock_credentials(
container_id: &str,
project: &Project,
) -> Result<(), String> {
let static_bedrock = if project.backend == Backend::Bedrock {
project
.bedrock_config
.as_ref()
.filter(|b| b.auth_method == BedrockAuthMethod::StaticCredentials)
} else {
None
};
let bedrock = match static_bedrock {
Some(b) if b.aws_access_key_id.as_deref().is_some_and(|k| !k.is_empty()) => b,
_ => {
// Not static-credential Bedrock (or static selected but no key set):
// remove a stale credentials file from a previous static session.
if matches!(static_bedrock, Some(_)) {
log::warn!("Bedrock static auth selected but no AWS access key id is set");
}
let script = r#"if [ ! -d /tmp/.host-aws ]; then rm -f "$HOME/.aws/credentials"; fi"#;
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
let env = vec!["HOME=/home/claude".to_string()];
if let Err(e) = crate::docker::exec::exec_oneshot_env(container_id, cmd, env).await {
log::warn!(
"Failed to clear stale AWS credentials in container {}: {}",
container_id,
e
);
}
return Ok(());
}
};
let key_id = bedrock.aws_access_key_id.as_deref().unwrap_or("");
let secret = bedrock.aws_secret_access_key.as_deref().unwrap_or("");
// Pass secrets via the exec environment, then have the shell write them to
// the file. This keeps them out of the process argv (visible via `ps`).
let mut env = vec![
"HOME=/home/claude".to_string(),
format!("TC_AWS_KEY_ID={}", key_id),
format!("TC_AWS_SECRET={}", secret),
];
if let Some(token) = bedrock.aws_session_token.as_deref() {
if !token.is_empty() {
env.push(format!("TC_AWS_TOKEN={}", token));
}
}
// umask 077 + explicit chmod guarantees 0600. The session-token line is only
// emitted when the variable is non-empty.
//
// We also remove a stale ~/.aws/config left over from a previous
// profile/SSO session on this project (the home volume persists across
// backend switches), so its sso_session/profile settings don't shadow the
// static [default] credentials. This is skipped when /tmp/.host-aws is
// mounted (a global aws_config_path is configured) — in that case the
// entrypoint already refreshes ~/.aws from the host on every start and the
// config is intentional.
let script = r#"set -e
umask 077
mkdir -p "$HOME/.aws"
if [ ! -d /tmp/.host-aws ] && [ -f "$HOME/.aws/config" ]; then
rm -f "$HOME/.aws/config"
fi
{
printf '[default]\n'
printf 'aws_access_key_id=%s\n' "$TC_AWS_KEY_ID"
printf 'aws_secret_access_key=%s\n' "$TC_AWS_SECRET"
if [ -n "${TC_AWS_TOKEN:-}" ]; then
printf 'aws_session_token=%s\n' "$TC_AWS_TOKEN"
fi
} > "$HOME/.aws/credentials"
chmod 600 "$HOME/.aws/credentials""#;
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
let (output, exit_code) =
crate::docker::exec::exec_oneshot_env_status(container_id, cmd, env)
.await
.map_err(|e| format!("Failed to write AWS credentials into container: {}", e))?;
if exit_code != 0 {
return Err(format!(
"Writing AWS credentials into container failed (exit {}): {}",
exit_code,
output.trim()
));
}
log::info!("Wrote Bedrock static credentials into container {}", container_id);
Ok(())
}
/// Commit the container's filesystem to a snapshot image so that system-level
/// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container
/// removal. The Config is left empty so that secrets injected as env vars are
/// NOT baked into the image.
/// removal.
///
/// NOTE: `docker commit` always bakes the *running container's* full ENV into
/// the resulting image — passing an empty Config here does NOT strip it, and
/// the commit API gives no way to remove env vars. As a result auth vars (e.g.
/// CLAUDE_CODE_USE_BEDROCK, AWS_*) are present in this snapshot image's ENV.
/// `create_container` defends against that by explicitly overriding every
/// managed auth key for the active backend (see MANAGED_AUTH_KEYS), so a
/// backend switch does not inherit the previous backend's stale credentials.
pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> Result<(), String> {
let docker = get_docker()?;
let image_name = get_snapshot_image_name(project);
@@ -1083,6 +1301,9 @@ pub async fn remove_project_volumes(project: &Project) -> Result<(), String> {
pub async fn container_needs_recreation(
container_id: &str,
project: &Project,
global_aws: &GlobalAwsSettings,
global_ollama: &GlobalOllamaSettings,
global_openai_compatible: &GlobalOpenAiCompatibleSettings,
global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar],
timezone: Option<&str>,
@@ -1154,7 +1375,7 @@ pub async fn container_needs_recreation(
}
// ── Bedrock config fingerprint ───────────────────────────────────────
let expected_bedrock_fp = compute_bedrock_fingerprint(project);
let expected_bedrock_fp = compute_bedrock_fingerprint(project, global_aws);
let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default();
if container_bedrock_fp != expected_bedrock_fp {
log::info!("Bedrock config mismatch");
@@ -1162,7 +1383,7 @@ pub async fn container_needs_recreation(
}
// ── Ollama config fingerprint ────────────────────────────────────────
let expected_ollama_fp = compute_ollama_fingerprint(project);
let expected_ollama_fp = compute_ollama_fingerprint(project, global_ollama);
let container_ollama_fp = get_label("triple-c.ollama-fingerprint").unwrap_or_default();
if container_ollama_fp != expected_ollama_fp {
log::info!("Ollama config mismatch");
@@ -1170,7 +1391,7 @@ pub async fn container_needs_recreation(
}
// ── OpenAI Compatible config fingerprint ────────────────────────────
let expected_oai_fp = compute_openai_compatible_fingerprint(project);
let expected_oai_fp = compute_openai_compatible_fingerprint(project, global_openai_compatible);
let container_oai_fp = get_label("triple-c.openai-compatible-fingerprint").unwrap_or_default();
if container_oai_fp != expected_oai_fp {
log::info!("OpenAI Compatible config mismatch");
+113 -4
View File
@@ -279,8 +279,90 @@ impl ExecSessionManager {
}
}
/// Upload a host file into the container's `/tmp` under `dest_name`. The file is
/// read and packed into the tar inside a blocking task, so the synchronous IO
/// runs off the async worker. The tar's declared entry size is taken from the
/// bytes actually read (not a separate `stat`), so a file changing size between
/// a size check and the read can't desync the header and corrupt the archive.
/// Returns the in-container path (`/tmp/<dest_name>`).
pub async fn upload_host_file_to_container(
container_id: &str,
host_path: &str,
dest_name: &str,
) -> Result<String, String> {
let host_path = host_path.to_string();
let dest_name = dest_name.to_string();
let dest_for_blk = dest_name.clone();
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
let data = std::fs::read(&host_path)
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
{
let mut builder = tar::Builder::new(&mut tar_buf);
let mut header = tar::Header::new_gnu();
// Size comes from the bytes in hand, so header and payload can't disagree.
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, &dest_for_blk, &data[..])
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
builder
.finish()
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
}
Ok(tar_buf)
})
.await
.map_err(|e| format!("Upload task panicked: {}", e))??;
let docker = get_docker()?;
docker
.upload_to_container(
container_id,
Some(UploadToContainerOptions {
path: "/tmp".to_string(),
..Default::default()
}),
tar_buf.into(),
)
.await
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
Ok(format!("/tmp/{}", dest_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> {
exec_oneshot_env(container_id, cmd, Vec::new()).await
}
/// Like `exec_oneshot`, but passes additional environment variables to the exec
/// process. Secrets passed this way live only in `/proc/<pid>/environ` (readable
/// by the same user / root) rather than in the process argv, so they are not
/// exposed via `ps`.
///
/// NOTE: the command's exit code is NOT checked — callers that need to know
/// whether the command succeeded should use `exec_oneshot_env_status`.
pub async fn exec_oneshot_env(
container_id: &str,
cmd: Vec<String>,
env: Vec<String>,
) -> Result<String, String> {
exec_oneshot_env_status(container_id, cmd, env)
.await
.map(|(output, _exit_code)| output)
}
/// Like `exec_oneshot_env`, but also returns the command's exit code (0 on
/// success). The returned string contains both stdout and stderr, interleaved
/// in arrival order, which is useful for surfacing failure detail.
pub async fn exec_oneshot_env_status(
container_id: &str,
cmd: Vec<String>,
env: Vec<String>,
) -> Result<(String, i64), String> {
let docker = get_docker()?;
let exec = docker
@@ -290,6 +372,7 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
attach_stdout: Some(true),
attach_stderr: Some(true),
cmd: Some(cmd),
env: if env.is_empty() { None } else { Some(env) },
user: Some("claude".to_string()),
..Default::default()
},
@@ -302,17 +385,43 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
.await
.map_err(|e| format!("Failed to start exec: {}", e))?;
let mut combined = String::new();
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())),
Ok(data) => combined.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()),
StartExecResults::Detached => return Err("Exec started in detached mode".to_string()),
}
// The output stream draining doesn't strictly guarantee inspect_exec has the
// final exit_code populated yet, so poll until the exec reports finished.
let exit_code = wait_for_exec_exit(&exec.id).await.unwrap_or(0);
Ok((combined, exit_code))
}
/// Poll `inspect_exec` until the exec reports finished and return its exit code.
/// Returns `None` if the code can't be determined (inspect error, or the exec
/// doesn't report finished within ~1s — which shouldn't happen once its output
/// stream has drained).
pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
let docker = get_docker().ok()?;
for _ in 0..40 {
match docker.inspect_exec(exec_id).await {
Ok(info) => {
if info.running != Some(true) {
// Finished: use the reported code (default 0 if somehow absent).
return Some(info.exit_code.unwrap_or(0));
}
}
Err(_) => return None,
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
None
}
+2
View File
@@ -178,12 +178,14 @@ pub fn run() {
commands::terminal_commands::terminal_resize,
commands::terminal_commands::close_terminal_session,
commands::terminal_commands::paste_image_to_terminal,
commands::terminal_commands::upload_host_file_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::download_container_backup,
commands::file_commands::upload_file_to_container,
// MCP
commands::mcp_commands::list_mcp_servers,
+25
View File
@@ -32,6 +32,8 @@ pub struct GlobalAwsSettings {
pub aws_profile: Option<String>,
#[serde(default)]
pub aws_region: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
}
impl Default for GlobalAwsSettings {
@@ -40,10 +42,27 @@ impl Default for GlobalAwsSettings {
aws_config_path: None,
aws_profile: None,
aws_region: None,
default_model_id: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlobalOllamaSettings {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlobalOpenAiCompatibleSettings {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppSettings {
#[serde(default)]
@@ -60,6 +79,10 @@ pub struct AppSettings {
pub custom_image_name: Option<String>,
#[serde(default)]
pub global_aws: GlobalAwsSettings,
#[serde(default)]
pub global_ollama: GlobalOllamaSettings,
#[serde(default)]
pub global_openai_compatible: GlobalOpenAiCompatibleSettings,
#[serde(default = "default_global_instructions")]
pub global_claude_instructions: Option<String>,
#[serde(default)]
@@ -156,6 +179,8 @@ impl Default for AppSettings {
image_source: ImageSource::default(),
custom_image_name: None,
global_aws: GlobalAwsSettings::default(),
global_ollama: GlobalOllamaSettings::default(),
global_openai_compatible: GlobalOpenAiCompatibleSettings::default(),
global_claude_instructions: default_global_instructions(),
global_custom_env_vars: Vec::new(),
auto_check_updates: true,
+6
View File
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -93,6 +95,9 @@ pub struct Project {
pub enabled_mcp_servers: Vec<String>,
#[serde(default)]
pub claude_code_settings: Option<ClaudeCodeSettings>,
/// User-defined display names for terminal tabs, keyed by session id.
#[serde(default)]
pub renamed_session_names: HashMap<String, String>,
pub created_at: String,
pub updated_at: String,
}
@@ -217,6 +222,7 @@ impl Project {
claude_instructions: None,
enabled_mcp_servers: Vec::new(),
claude_code_settings: None,
renamed_session_names: HashMap::new(),
created_at: now.clone(),
updated_at: now,
}
+14 -3
View File
@@ -10,6 +10,8 @@ import { useSettings } from "./hooks/useSettings";
import { useProjects } from "./hooks/useProjects";
import { useMcpServers } from "./hooks/useMcpServers";
import { useUpdates } from "./hooks/useUpdates";
import { useTerminal } from "./hooks/useTerminal";
import { useSTT } from "./hooks/useSTT";
import { useAppState } from "./store/appState";
import { reconcileProjectStatuses } from "./lib/tauri-commands";
@@ -19,11 +21,20 @@ export default function App() {
const { refresh } = useProjects();
const { refresh: refreshMcp } = useMcpServers();
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
const { sessions, activeSessionId, setProjects } = useAppState(
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects }))
const { sessions, activeSessionId, setProjects, setSttToggle } = useAppState(
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects, setSttToggle: s.setSttToggle }))
);
const [showInstallDialog, setShowInstallDialog] = useState(false);
// Single STT instance bound to the active session. The mic lives in the
// StatusBar; the terminal's Ctrl+Shift+M shortcut calls stt.toggle via the
// store (registered below).
const { sendInput } = useTerminal();
const stt = useSTT(activeSessionId ?? "", sendInput);
useEffect(() => {
setSttToggle(stt.toggle);
}, [stt.toggle, setSttToggle]);
// Initialize on mount
useEffect(() => {
loadSettings();
@@ -82,7 +93,7 @@ export default function App() {
)}
</main>
</div>
<StatusBar />
<StatusBar stt={stt} />
{showInstallDialog && (
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
)}
@@ -8,6 +8,9 @@ vi.mock("../../store/appState", () => ({
selector({
sidebarView: "projects",
setSidebarView: vi.fn(),
sidebarCollapsed: false,
setSidebarCollapsed: vi.fn(),
toggleSidebarCollapsed: vi.fn(),
})
),
}));
+98 -3
View File
@@ -1,15 +1,100 @@
import type { ReactNode } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../../store/appState";
import ProjectList from "../projects/ProjectList";
import McpPanel from "../mcp/McpPanel";
import SettingsPanel from "../settings/SettingsPanel";
type SidebarView = "projects" | "mcp" | "settings";
const RAIL_ICONS: { view: SidebarView; label: string; icon: ReactNode }[] = [
{
view: "projects",
label: "Projects",
icon: (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" />
</svg>
),
},
{
view: "mcp",
label: "MCP",
icon: (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 2v6" />
<path d="M15 2v6" />
<path d="M7 8h10v4a5 5 0 0 1-10 0V8z" />
<path d="M12 17v5" />
</svg>
),
},
{
view: "settings",
label: "Settings",
icon: (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
),
},
];
export default function Sidebar() {
const { sidebarView, setSidebarView } = useAppState(
useShallow(s => ({ sidebarView: s.sidebarView, setSidebarView: s.setSidebarView }))
const { sidebarView, setSidebarView, sidebarCollapsed, setSidebarCollapsed, toggleSidebarCollapsed } = useAppState(
useShallow(s => ({
sidebarView: s.sidebarView,
setSidebarView: s.setSidebarView,
sidebarCollapsed: s.sidebarCollapsed,
setSidebarCollapsed: s.setSidebarCollapsed,
toggleSidebarCollapsed: s.toggleSidebarCollapsed,
}))
);
const tabCls = (view: typeof sidebarView) =>
if (sidebarCollapsed) {
const railBtn = (view: SidebarView, label: string, icon: ReactNode) => {
const active = sidebarView === view;
return (
<button
key={view}
onClick={() => {
setSidebarView(view);
setSidebarCollapsed(false);
}}
title={label}
aria-label={label}
className={`flex items-center justify-center h-10 w-full transition-colors ${
active
? "text-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{icon}
</button>
);
};
return (
<div className="flex flex-col h-full w-12 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
<button
onClick={toggleSidebarCollapsed}
title="Expand sidebar"
aria-label="Expand sidebar"
className="flex items-center justify-center h-10 border-b border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<div className="flex flex-col py-1">
{RAIL_ICONS.map(({ view, label, icon }) => railBtn(view, label, icon))}
</div>
</div>
);
}
const tabCls = (view: SidebarView) =>
`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
sidebarView === view
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
@@ -29,6 +114,16 @@ export default function Sidebar() {
<button onClick={() => setSidebarView("settings")} className={tabCls("settings")}>
Settings
</button>
<button
onClick={toggleSidebarCollapsed}
title="Collapse sidebar"
aria-label="Collapse sidebar"
className="px-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</div>
{/* Content */}
+40 -3
View File
@@ -1,9 +1,26 @@
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../../store/appState";
import SttButton from "../terminal/SttButton";
import type { useSTT } from "../../hooks/useSTT";
export default function StatusBar() {
const { projects, sessions, terminalHasSelection } = useAppState(
useShallow(s => ({ projects: s.projects, sessions: s.sessions, terminalHasSelection: s.terminalHasSelection }))
interface Props {
stt: ReturnType<typeof useSTT>;
}
export default function StatusBar({ stt }: Props) {
const {
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
terminalAtBottom, scrollActiveToBottom,
} = useAppState(
useShallow(s => ({
projects: s.projects,
sessions: s.sessions,
terminalHasSelection: s.terminalHasSelection,
activeSessionId: s.activeSessionId,
sttEnabled: s.appSettings?.stt?.enabled,
terminalAtBottom: s.terminalAtBottom,
scrollActiveToBottom: s.scrollActiveToBottom,
}))
);
const running = projects.filter((p) => p.status === "running").length;
@@ -28,6 +45,26 @@ export default function StatusBar() {
</span>
</>
)}
{/* Right-aligned controls: Jump to Current + STT mic */}
<div className="ml-auto flex items-center gap-3 pl-2">
{activeSessionId && !terminalAtBottom && (
<button
onClick={() => scrollActiveToBottom()}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
title="Scroll the terminal to the latest output"
>
Jump to Current
</button>
)}
{sttEnabled && activeSessionId && (
<SttButton
state={stt.state}
error={stt.error}
onToggle={stt.toggle}
onCancel={stt.cancelRecording}
/>
)}
</div>
</div>
);
}
@@ -70,7 +70,7 @@ export default function FileManagerModal({ projectId, projectName, onClose }: Pr
>
<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)]">
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-color)] flex-shrink-0">
<h2 className="text-sm font-semibold">Files {projectName}</h2>
<button
onClick={onClose}
@@ -177,7 +177,7 @@ export default function FileManagerModal({ projectId, projectName, onClose }: Pr
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-color)]">
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-color)] flex-shrink-0">
<button
onClick={uploadFile}
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
+41 -1
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { open, save } from "@tauri-apps/plugin-dialog";
import * as commands from "../../lib/tauri-commands";
import { listen } from "@tauri-apps/api/event";
import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, OpenAiCompatibleConfig } from "../../lib/types";
import { useProjects } from "../../hooks/useProjects";
@@ -37,6 +38,7 @@ export default function ProjectCard({ project }: Props) {
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
const [operationCompleted, setOperationCompleted] = useState(false);
const [showRemoveModal, setShowRemoveModal] = useState(false);
const [backingUp, setBackingUp] = useState(false);
const [isEditingName, setIsEditingName] = useState(false);
const [editName, setEditName] = useState(project.name);
const isSelected = selectedProjectId === project.id;
@@ -177,6 +179,35 @@ export default function ProjectCard({ project }: Props) {
}
};
const handleBackup = async () => {
if (!project.container_id) {
setError("Start the project at least once before backing up.");
return;
}
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
const safeName = project.name.replace(/[^a-zA-Z0-9_-]+/g, "_");
try {
const hostPath = await save({
defaultPath: `${safeName}-backup-${stamp}.tar.gz`,
filters: [{ name: "Gzipped tarball", extensions: ["tar.gz"] }],
});
if (!hostPath) return;
setBackingUp(true);
setError(null);
const bytes = await commands.downloadContainerBackup(project.id, hostPath);
const mb = (bytes / (1024 * 1024)).toFixed(1);
const msg = `Backup saved (${mb} MB). Note: includes MCP/config — may contain MCP API keys. Keep it private.`;
setProgressMsg(msg);
// Auto-clear so the transient confirmation doesn't linger in the card
// status; guard against clobbering a newer message (e.g. a later op).
setTimeout(() => setProgressMsg((prev) => (prev === msg ? null : prev)), 8000);
} catch (e) {
setError(String(e));
} finally {
setBackingUp(false);
}
};
const closeModal = () => {
setActiveOperation(null);
setOperationCompleted(false);
@@ -504,6 +535,12 @@ export default function ProjectCard({ project }: Props) {
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
<ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" />
<ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" />
<ActionButton
onClick={handleBackup}
disabled={loading || backingUp}
label={backingUp ? "Backing up…" : "Backup"}
title="Downloads /workspace plus a sanitized home config (MCP servers, settings, skills). OAuth tokens are excluded, but MCP server configs may embed their own API keys/tokens — keep the archive private."
/>
</>
) : (
<>
@@ -1193,12 +1230,14 @@ function ActionButton({
label,
accent,
danger,
title,
}: {
onClick: (e?: React.MouseEvent) => void;
disabled: boolean;
label: string;
accent?: boolean;
danger?: boolean;
title?: string;
}) {
let color = "text-[var(--text-secondary)] hover:text-[var(--text-primary)]";
if (accent) color = "text-[var(--accent)] hover:text-[var(--accent-hover)]";
@@ -1208,6 +1247,7 @@ function ActionButton({
<button
onClick={(e) => { e.stopPropagation(); onClick(e); }}
disabled={disabled}
title={title}
className={`text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-50 ${color} hover:bg-[var(--bg-primary)]`}
>
{label}
@@ -12,6 +12,7 @@ export default function AwsSettings() {
aws_config_path: null,
aws_profile: null,
aws_region: null,
default_model_id: null,
};
// Load profiles when component mounts or aws_config_path changes
@@ -105,6 +106,18 @@ export default function AwsSettings() {
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
{/* Default Model ID */}
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Model ID<Tooltip text="Default Bedrock model ID. Used when a Bedrock project doesn't set its own Model ID." /></span>
<input
type="text"
value={globalAws.default_model_id ?? ""}
onChange={(e) => handleChange("default_model_id", e.target.value)}
placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
</div>
</div>
);
@@ -0,0 +1,53 @@
import { useSettings } from "../../hooks/useSettings";
import Tooltip from "../ui/Tooltip";
export default function OllamaSettings() {
const { appSettings, saveSettings } = useSettings();
const globalOllama = appSettings?.global_ollama ?? {
base_url: null,
default_model_id: null,
};
const handleChange = async (field: "base_url" | "default_model_id", value: string) => {
if (!appSettings) return;
await saveSettings({
...appSettings,
global_ollama: { ...globalOllama, [field]: value || null },
});
};
return (
<div>
<label className="block text-sm font-medium mb-2">Ollama Configuration</label>
<div className="space-y-3 text-sm">
<p className="text-xs text-[var(--text-secondary)]">
Global Ollama defaults. Used when a per-project field is blank.
Changes here require a container rebuild to take effect.
</p>
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Base URL<Tooltip text="URL of your Ollama server. Used when a per-project Ollama base URL is blank." /></span>
<input
type="text"
value={globalOllama.base_url ?? ""}
onChange={(e) => handleChange("base_url", e.target.value)}
placeholder="http://host.docker.internal:11434"
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Model<Tooltip text="Default Ollama model name. Used when a per-project Ollama model is blank." /></span>
<input
type="text"
value={globalOllama.default_model_id ?? ""}
onChange={(e) => handleChange("default_model_id", e.target.value)}
placeholder="qwen3.5:27b"
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,53 @@
import { useSettings } from "../../hooks/useSettings";
import Tooltip from "../ui/Tooltip";
export default function OpenAiCompatibleSettings() {
const { appSettings, saveSettings } = useSettings();
const globalOai = appSettings?.global_openai_compatible ?? {
base_url: null,
default_model_id: null,
};
const handleChange = async (field: "base_url" | "default_model_id", value: string) => {
if (!appSettings) return;
await saveSettings({
...appSettings,
global_openai_compatible: { ...globalOai, [field]: value || null },
});
};
return (
<div>
<label className="block text-sm font-medium mb-2">OpenAI Compatible Configuration</label>
<div className="space-y-3 text-sm">
<p className="text-xs text-[var(--text-secondary)]">
Global defaults for any OpenAI-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.).
Used when a per-project field is blank. Changes require a container rebuild.
</p>
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Base URL<Tooltip text="Default OpenAI-compatible endpoint URL. Used when a per-project base URL is blank." /></span>
<input
type="text"
value={globalOai.base_url ?? ""}
onChange={(e) => handleChange("base_url", e.target.value)}
placeholder="http://host.docker.internal:4000"
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Model<Tooltip text="Default model identifier. Used when a per-project model is blank." /></span>
<input
type="text"
value={globalOai.default_model_id ?? ""}
onChange={(e) => handleChange("default_model_id", e.target.value)}
placeholder="gpt-4o / gemini-pro / etc."
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
</div>
</div>
);
}
+79 -65
View File
@@ -1,6 +1,8 @@
import { useState, useEffect } from "react";
import DockerSettings from "./DockerSettings";
import AwsSettings from "./AwsSettings";
import OllamaSettings from "./OllamaSettings";
import OpenAiCompatibleSettings from "./OpenAiCompatibleSettings";
import { useSettings } from "../../hooks/useSettings";
import { useUpdates } from "../../hooks/useUpdates";
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
@@ -9,6 +11,7 @@ import EnvVarsModal from "../projects/EnvVarsModal";
import { detectHostTimezone } from "../../lib/tauri-commands";
import type { EnvVar } from "../../lib/types";
import Tooltip from "../ui/Tooltip";
import AccordionSection from "../ui/AccordionSection";
import WebTerminalSettings from "./WebTerminalSettings";
import SttSettings from "./SttSettings";
@@ -61,67 +64,12 @@ export default function SettingsPanel() {
};
return (
<div className="p-4 space-y-6">
<div className="p-4 space-y-3">
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
Settings
</h2>
<DockerSettings />
<AwsSettings />
{/* Default SSH Key Directory */}
<div>
<label className="block text-sm font-medium mb-1">Default SSH Key Directory<Tooltip text="Global default SSH key directory. Mounted into containers that don't have a per-project SSH path set." /></label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Mounted into all containers unless overridden by a per-project setting.
</p>
<input
type="text"
value={sshKeyPath}
onChange={(e) => setSshKeyPath(e.target.value)}
onBlur={async () => {
if (appSettings) {
await saveSettings({ ...appSettings, default_ssh_key_path: sshKeyPath || null });
}
}}
placeholder="~/.ssh"
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
{/* Default Git Name */}
<div>
<label className="block text-sm font-medium mb-1">Default Git Name<Tooltip text="Sets git user.name inside containers. Per-project Git Name takes precedence." /></label>
<input
type="text"
value={gitName}
onChange={(e) => setGitName(e.target.value)}
onBlur={async () => {
if (appSettings) {
await saveSettings({ ...appSettings, default_git_user_name: gitName || null });
}
}}
placeholder="Your Name"
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
{/* Default Git Email */}
<div>
<label className="block text-sm font-medium mb-1">Default Git Email<Tooltip text="Sets git user.email inside containers. Per-project Git Email takes precedence." /></label>
<input
type="text"
value={gitEmail}
onChange={(e) => setGitEmail(e.target.value)}
onBlur={async () => {
if (appSettings) {
await saveSettings({ ...appSettings, default_git_user_email: gitEmail || null });
}
}}
placeholder="you@example.com"
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
<AccordionSection id="general" title="General">
{/* Container Timezone */}
<div>
<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>
@@ -198,16 +146,82 @@ export default function SettingsPanel() {
</button>
</div>
</div>
</AccordionSection>
{/* Web Terminal */}
<WebTerminalSettings />
<AccordionSection id="backends" title="Backends" defaultOpen={false}>
<AwsSettings />
<div className="pt-3 border-t border-[var(--border-color)]" />
<OllamaSettings />
<div className="pt-3 border-t border-[var(--border-color)]" />
<OpenAiCompatibleSettings />
</AccordionSection>
{/* Speech to Text */}
<SttSettings />
<AccordionSection id="container" title="Container" defaultOpen={false}>
<DockerSettings />
</AccordionSection>
{/* Updates section */}
<AccordionSection id="git-ssh" title="Git / SSH" defaultOpen={false}>
{/* Default SSH Key Directory */}
<div>
<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>
<label className="block text-sm font-medium mb-1">Default SSH Key Directory<Tooltip text="Global default SSH key directory. Mounted into containers that don't have a per-project SSH path set." /></label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Mounted into all containers unless overridden by a per-project setting.
</p>
<input
type="text"
value={sshKeyPath}
onChange={(e) => setSshKeyPath(e.target.value)}
onBlur={async () => {
if (appSettings) {
await saveSettings({ ...appSettings, default_ssh_key_path: sshKeyPath || null });
}
}}
placeholder="~/.ssh"
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
{/* Default Git Name */}
<div>
<label className="block text-sm font-medium mb-1">Default Git Name<Tooltip text="Sets git user.name inside containers. Per-project Git Name takes precedence." /></label>
<input
type="text"
value={gitName}
onChange={(e) => setGitName(e.target.value)}
onBlur={async () => {
if (appSettings) {
await saveSettings({ ...appSettings, default_git_user_name: gitName || null });
}
}}
placeholder="Your Name"
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
{/* Default Git Email */}
<div>
<label className="block text-sm font-medium mb-1">Default Git Email<Tooltip text="Sets git user.email inside containers. Per-project Git Email takes precedence." /></label>
<input
type="text"
value={gitEmail}
onChange={(e) => setGitEmail(e.target.value)}
onBlur={async () => {
if (appSettings) {
await saveSettings({ ...appSettings, default_git_user_email: gitEmail || null });
}
}}
placeholder="you@example.com"
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
</AccordionSection>
<AccordionSection id="tools" title="Tools" defaultOpen={false}>
<WebTerminalSettings />
<SttSettings />
</AccordionSection>
<AccordionSection id="updates" title="Updates" defaultOpen={false}>
<div className="space-y-2">
{appVersion && (
<p className="text-xs text-[var(--text-secondary)]">
@@ -237,11 +251,11 @@ export default function SettingsPanel() {
{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>
<span>A newer container image is available. Re-pull the image in Container settings above to update.</span>
</div>
)}
</div>
</div>
</AccordionSection>
{showInstructionsModal && (
<ClaudeInstructionsModal
+16 -18
View File
@@ -62,7 +62,15 @@ export default function SttButton({ state, error, onToggle, onCancel }: Props) {
};
return (
<div className="absolute bottom-2 left-2 z-50 flex items-center gap-2">
<div className="flex items-center gap-1.5">
{state === "recording" && (
<span className="text-[#f85149] font-mono">{formatTime(elapsed)}</span>
)}
{state === "error" && error && (
<span className="text-[#f85149] max-w-[180px] truncate" title={error}>
{error}
</span>
)}
<div className="relative">
<button
onClick={handleClick}
@@ -71,44 +79,34 @@ export default function SttButton({ state, error, onToggle, onCancel }: Props) {
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
disabled={state === "transcribing"}
className={`w-8 h-8 rounded-full flex items-center justify-center transition-all cursor-pointer ${
className={`w-5 h-5 rounded-full flex items-center justify-center transition-all cursor-pointer ${
state === "recording"
? "bg-[#f85149] text-white shadow-lg animate-pulse"
? "bg-[#f85149] text-white animate-pulse"
: state === "transcribing"
? "bg-[#1f2937] text-[#58a6ff] border border-[#30363d] opacity-80"
: "bg-[#1f2937]/80 text-[#8b949e] border border-[#30363d] hover:text-[#e6edf3] hover:bg-[#2d3748]"
? "text-[#58a6ff] opacity-80"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]"
}`}
>
{state === "transcribing" ? (
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" opacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
) : (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
</svg>
)}
</button>
{hovered && state !== "recording" && (
<div className="absolute bottom-full left-0 mb-1.5 px-2 py-1 text-[11px] leading-snug text-[#e6edf3] bg-[#21262d] border border-[#30363d] rounded shadow-lg whitespace-nowrap pointer-events-none">
<div className="absolute bottom-full right-0 mb-1.5 px-2 py-1 text-[11px] leading-snug text-[#e6edf3] bg-[#21262d] border border-[#30363d] rounded shadow-lg whitespace-nowrap pointer-events-none z-50">
{state === "transcribing" ? "Transcribing..." : (
<>Speech to text <kbd className="ml-1 px-1 py-0.5 text-[10px] bg-[#0d1117] border border-[#30363d] rounded font-mono">Ctrl+Shift+M</kbd></>
)}
</div>
)}
</div>
{state === "recording" && (
<span className="text-xs text-[#f85149] font-mono bg-[#1f2937] px-2 py-0.5 rounded border border-[#30363d]">
{formatTime(elapsed)}
</span>
)}
{state === "error" && error && (
<span className="text-xs text-[#f85149] bg-[#1f2937] px-2 py-0.5 rounded border border-[#30363d] max-w-[200px] truncate">
{error}
</span>
)}
</div>
);
}
+162 -4
View File
@@ -1,7 +1,38 @@
import { useEffect, useRef, useState } from "react";
import { useTerminal } from "../../hooks/useTerminal";
import { useProjects } from "../../hooks/useProjects";
interface ContextMenuState {
sessionId: string;
x: number;
y: number;
}
export default function TerminalTabs() {
const { sessions, activeSessionId, setActiveSession, close } = useTerminal();
const { projects, update } = useProjects();
const [menu, setMenu] = useState<ContextMenuState | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState("");
const renameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!menu) return;
const dismiss = () => setMenu(null);
window.addEventListener("click", dismiss);
window.addEventListener("scroll", dismiss, true);
return () => {
window.removeEventListener("click", dismiss);
window.removeEventListener("scroll", dismiss, true);
};
}, [menu]);
useEffect(() => {
if (renamingId) {
renameInputRef.current?.focus();
renameInputRef.current?.select();
}
}, [renamingId]);
if (sessions.length === 0) {
return (
@@ -11,21 +42,111 @@ export default function TerminalTabs() {
);
}
const getCustomName = (projectId: string, sessionId: string): string | null => {
const project = projects.find((p) => p.id === projectId);
return project?.renamed_session_names?.[sessionId] ?? null;
};
const startRename = (sessionId: string) => {
const session = sessions.find((s) => s.id === sessionId);
if (!session) return;
const current = getCustomName(session.projectId, sessionId) ?? session.sessionName ?? session.projectName;
setRenameDraft(current);
setRenamingId(sessionId);
setMenu(null);
};
const commitRename = async (sessionId: string) => {
const session = sessions.find((s) => s.id === sessionId);
if (!session) {
setRenamingId(null);
return;
}
const project = projects.find((p) => p.id === session.projectId);
if (!project) {
setRenamingId(null);
return;
}
const trimmed = renameDraft.trim();
const map = { ...(project.renamed_session_names ?? {}) };
if (trimmed) {
map[sessionId] = trimmed;
} else {
delete map[sessionId];
}
try {
await update({ ...project, renamed_session_names: map });
} catch (err) {
console.error("Failed to rename terminal tab:", err);
} finally {
setRenamingId(null);
}
};
const clearCustomName = async (sessionId: string) => {
const session = sessions.find((s) => s.id === sessionId);
if (!session) return;
const project = projects.find((p) => p.id === session.projectId);
if (!project) return;
const map = { ...(project.renamed_session_names ?? {}) };
if (!(sessionId in map)) {
setMenu(null);
return;
}
delete map[sessionId];
try {
await update({ ...project, renamed_session_names: map });
} catch (err) {
console.error("Failed to reset terminal tab name:", err);
} finally {
setMenu(null);
}
};
return (
<div className="flex items-center h-full">
{sessions.map((session) => (
{sessions.map((session) => {
const customName = getCustomName(session.projectId, session.id);
const baseLabel =
(session.sessionName ?? session.projectName) +
(session.sessionType === "bash" ? " (bash)" : "");
const displayLabel = customName
? `${session.projectName}: ${customName}`
: baseLabel;
const isRenaming = renamingId === session.id;
return (
<div
key={session.id}
onClick={() => setActiveSession(session.id)}
onContextMenu={(e) => {
e.preventDefault();
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
}}
onDoubleClick={() => startRename(session.id)}
className={`flex items-center gap-2 px-3 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${
activeSessionId === session.id
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
<span className="truncate max-w-[140px]">
{session.sessionName ?? session.projectName}{session.sessionType === "bash" ? " (bash)" : ""}
{isRenaming ? (
<input
ref={renameInputRef}
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onBlur={() => commitRename(session.id)}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
if (e.key === "Escape") setRenamingId(null);
}}
className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded text-xs text-[var(--text-primary)] focus:outline-none"
/>
) : (
<span className="truncate max-w-[200px]" title={displayLabel}>
{displayLabel}
</span>
)}
<button
onClick={(e) => {
e.stopPropagation();
@@ -37,7 +158,44 @@ export default function TerminalTabs() {
×
</button>
</div>
))}
);
})}
{menu && (() => {
const session = sessions.find((s) => s.id === menu.sessionId);
const hasCustom = session ? !!getCustomName(session.projectId, menu.sessionId) : false;
return (
<div
className="fixed z-50 min-w-[160px] py-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded shadow-lg text-xs"
style={{ top: menu.y, left: menu.x }}
onClick={(e) => e.stopPropagation()}
>
<button
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-primary)] transition-colors"
onClick={() => startRename(menu.sessionId)}
>
Rename tab
</button>
{hasCustom && (
<button
className="w-full text-left px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--bg-primary)] transition-colors"
onClick={() => clearCustomName(menu.sessionId)}
>
Reset name
</button>
)}
<div className="border-t border-[var(--border-color)] my-1" />
<button
className="w-full text-left px-3 py-1.5 text-[var(--error)] hover:bg-[var(--bg-primary)] transition-colors"
onClick={() => {
close(menu.sessionId);
setMenu(null);
}}
>
Close tab
</button>
</div>
);
})()}
</div>
);
}
+103 -21
View File
@@ -7,9 +7,8 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState";
import { useSTT } from "../../hooks/useSTT";
import SttButton from "./SttButton";
import { awsSsoRefresh } from "../../lib/tauri-commands";
import { awsSsoRefresh, uploadHostFileToTerminal } from "../../lib/tauri-commands";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { UrlDetector } from "../../lib/urlDetector";
import UrlToast from "./UrlToast";
import { trimSelection } from "./trimSelection";
@@ -29,10 +28,8 @@ export default function TerminalView({ sessionId, active }: Props) {
const detectorRef = useRef<UrlDetector | null>(null);
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
const sttEnabled = useAppState(s => s.appSettings?.stt?.enabled);
const stt = useSTT(sessionId, sendInput);
const sttToggleRef = useRef(stt.toggle);
sttToggleRef.current = stt.toggle;
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
const ssoBufferRef = useRef("");
const ssoTriggeredRef = useRef(false);
@@ -51,6 +48,72 @@ export default function TerminalView({ sessionId, active }: Props) {
const autoFollowRef = useRef(true);
const lastUserScrollTimeRef = useRef(0);
// Keep latest `active` readable inside long-lived listeners (drag-drop below,
// and the unmount-cleanup effect further down).
const activeRef = useRef(active);
activeRef.current = active;
// File drag-and-drop: dropped files are copied into the container and their
// in-container paths typed into the prompt so Claude Code can read them.
// Tauri intercepts OS file drops at the webview level, so we use
// onDragDropEvent (HTML5 ondrop on the element wouldn't expose file paths).
// The listener is window-wide, so we route purely by a hit-test against this
// terminal's bounds: the pane the drop lands on handles it. Inactive panes are
// `display:none` (zero-size rect) so they never match — this works for the
// current tabbed layout and would also do the right thing with split panes.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
const insideThisTerminal = (pos: { x: number; y: number }): boolean => {
const rect = containerRef.current?.getBoundingClientRect();
// A hidden (display:none) pane has a zero-size rect — never a drop target.
if (!rect || rect.width === 0 || rect.height === 0) return false;
const dpr = window.devicePixelRatio || 1;
const x = pos.x / dpr;
const y = pos.y / dpr;
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
};
// Always single-quote: a dropped filename can contain shell metacharacters
// ($(), &&, ', spaces) even with no whitespace, and this path is typed into
// a live shell. Single-quoting with '\'' escaping neutralizes all of them.
const quote = (p: string) => `'${p.replace(/'/g, "'\\''")}'`;
(async () => {
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
if (event.payload.type !== "drop") return;
if (!insideThisTerminal(event.payload.position)) return;
const paths = event.payload.paths ?? [];
if (paths.length === 0) return;
setImagePasteMsg(`Adding ${paths.length} file${paths.length > 1 ? "s" : ""}`);
const containerPaths: string[] = [];
for (const p of paths) {
try {
containerPaths.push(await uploadHostFileToTerminal(sessionId, p));
} catch (err) {
console.error("File drop upload failed for", p, err);
}
}
if (containerPaths.length === 0) {
setImagePasteMsg("File drop failed");
return;
}
sendInput(sessionId, containerPaths.map(quote).join(" ") + " ");
setImagePasteMsg(`Added ${containerPaths.length} file path${containerPaths.length > 1 ? "s" : ""}`);
});
if (cancelled) un();
else unlisten = un;
})();
return () => {
cancelled = true;
unlisten?.();
};
}, [sessionId, sendInput]);
useEffect(() => {
if (!containerRef.current) return;
@@ -111,9 +174,10 @@ export default function TerminalView({ sessionId, active }: Props) {
}
return false; // prevent xterm from processing this key
}
// Ctrl+Shift+M toggles speech-to-text recording
// Ctrl+Shift+M toggles speech-to-text recording (mic lives in the status
// bar, bound to the active session; trigger it via the store).
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "M") {
sttToggleRef.current();
useAppState.getState().sttToggle();
return false;
}
return true;
@@ -319,6 +383,7 @@ export default function TerminalView({ sessionId, active }: Props) {
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
webglRef.current = null;
term.dispose();
termRef.current = null;
};
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -393,6 +458,27 @@ export default function TerminalView({ sessionId, active }: Props) {
}
}, []);
// Surface this terminal's scroll state to the status bar's "Jump to Current"
// control, but only while it's the active (visible) terminal.
useEffect(() => {
if (!active) return;
setTerminalAtBottom(isAtBottom);
setScrollActiveToBottom(handleScrollToBottom);
}, [active, isAtBottom, handleScrollToBottom, setTerminalAtBottom, setScrollActiveToBottom]);
// On unmount, if this was the active terminal, clear the status-bar scroll
// state so it doesn't point at a disposed terminal. (Tab switches don't
// unmount — the deactivating terminal stays mounted but hidden — so this
// only fires when the active session is actually closed.)
useEffect(() => {
return () => {
if (activeRef.current) {
setTerminalAtBottom(true);
setScrollActiveToBottom(() => {});
}
};
}, [setTerminalAtBottom, setScrollActiveToBottom]);
const writeSelection = useCallback((mode: "trimmed" | "raw") => {
const term = termRef.current;
if (!term) return;
@@ -457,23 +543,19 @@ export default function TerminalView({ sessionId, active }: Props) {
>
{isAutoFollow ? "▼ Following" : "▽ Paused"}
</button>
{/* STT mic button - bottom left */}
{sttEnabled && <SttButton state={stt.state} error={stt.error} onToggle={stt.toggle} onCancel={stt.cancelRecording} />}
{/* Jump to Current - bottom right, when scrolled up */}
{!isAtBottom && (
<button
onClick={handleScrollToBottom}
className="absolute bottom-4 right-4 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#58a6ff] border border-[#30363d] shadow-lg hover:bg-[#2d3748] transition-colors cursor-pointer"
>
Jump to Current
</button>
)}
{/* Padding lives on this wrapper, NOT on the xterm host element. xterm's
FitAddon measures the host element it's mounted into; padding there
causes the grid to overhang and clip the rightmost column / bottom
row. The host below fills this wrapper's content box with no padding.
Kept to a tight, even gutter so the terminal claims as much area as
possible while leaving a little breathing room beside the scrollbar. */}
<div className="w-full h-full" style={{ padding: "4px 8px 4px 8px" }}>
<div
ref={containerRef}
className="w-full h-full"
style={{ padding: "8px 12px 48px 16px" }}
onContextMenu={handleContextMenu}
/>
</div>
{contextMenu && (
<TerminalContextMenu
x={contextMenu.x}
@@ -0,0 +1,65 @@
import { useEffect, useState, type ReactNode } from "react";
interface Props {
id: string;
title: string;
defaultOpen?: boolean;
storageKey?: string;
children: ReactNode;
}
function loadOpenState(storageKey: string, fallback: boolean): boolean {
try {
const stored = localStorage.getItem(storageKey);
if (stored === null) return fallback;
return stored === "1";
} catch {
return fallback;
}
}
function persistOpenState(storageKey: string, open: boolean) {
try {
localStorage.setItem(storageKey, open ? "1" : "0");
} catch {
// ignore
}
}
export default function AccordionSection({ id, title, defaultOpen = true, storageKey, children }: Props) {
const key = storageKey ?? `triple-c.accordion.${id}`;
const [open, setOpen] = useState<boolean>(() => loadOpenState(key, defaultOpen));
useEffect(() => {
persistOpenState(key, open);
}, [key, open]);
return (
<div className="border border-[var(--border-color)] rounded">
<button
type="button"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
className="w-full flex items-center justify-between px-3 py-2 text-left text-sm font-medium text-[var(--text-primary)] hover:bg-[var(--bg-primary)] transition-colors"
>
<span>{title}</span>
<svg
className={`w-4 h-4 text-[var(--text-secondary)] transition-transform ${open ? "rotate-90" : ""}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
{open && (
<div className="px-3 py-3 border-t border-[var(--border-color)] space-y-4">
{children}
</div>
)}
</div>
);
}
+9 -3
View File
@@ -13,6 +13,11 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
const streamRef = useRef<MediaStream | null>(null);
const workletRef = useRef<AudioWorkletNode | null>(null);
const chunksRef = useRef<Int16Array[]>([]);
// Pin the transcript to the terminal that was active when recording STARTED.
// The hook is bound to the live active session, which can change mid-recording
// (the user switches tabs); without this the transcript would land in whatever
// tab is active at stop time.
const recordingSessionIdRef = useRef(sessionId);
const appSettings = useAppState((s) => s.appSettings);
const deviceId = appSettings?.default_microphone;
@@ -22,6 +27,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
setState("recording");
setError(null);
chunksRef.current = [];
recordingSessionIdRef.current = sessionId;
try {
const audioConstraints: MediaTrackConstraints = {
@@ -57,7 +63,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
setError(msg);
setState("error");
}
}, [state, deviceId]);
}, [state, deviceId, sessionId]);
const stopRecording = useCallback(async () => {
if (state !== "recording") return;
@@ -102,7 +108,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
const text = await commands.transcribeAudio(audioData);
if (text) {
await sendInput(sessionId, text);
await sendInput(recordingSessionIdRef.current, text);
}
setState("idle");
} catch (e) {
@@ -112,7 +118,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
// Reset to idle after a brief delay so the UI shows the error
setTimeout(() => setState("idle"), 3000);
}
}, [state, sessionId, sendInput]);
}, [state, sendInput]);
const cancelRecording = useCallback(async () => {
workletRef.current?.disconnect();
+17
View File
@@ -28,8 +28,25 @@ export function useTerminal() {
const close = useCallback(
async (sessionId: string) => {
// Capture session/project info before we drop it from local state.
const { sessions: currentSessions, projects } = useAppState.getState();
const session = currentSessions.find((s) => s.id === sessionId);
const project = session ? projects.find((p) => p.id === session.projectId) : undefined;
await commands.closeTerminalSession(sessionId);
removeSession(sessionId);
// Drop any persisted custom name for this session.
if (project && project.renamed_session_names && sessionId in project.renamed_session_names) {
const map = { ...project.renamed_session_names };
delete map[sessionId];
try {
const updated = await commands.updateProject({ ...project, renamed_session_names: map });
useAppState.getState().updateProjectInList(updated);
} catch (err) {
console.error("Failed to clear renamed tab name on close:", err);
}
}
},
[removeSession],
);
+4
View File
@@ -55,6 +55,8 @@ 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 uploadHostFileToTerminal = (sessionId: string, hostPath: string) =>
invoke<string>("upload_host_file_to_terminal", { sessionId, hostPath });
export const startAudioBridge = (sessionId: string) =>
invoke<void>("start_audio_bridge", { sessionId });
export const sendAudioData = (sessionId: string, data: number[]) =>
@@ -76,6 +78,8 @@ 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 downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) =>
invoke<number>("download_container_backup", { projectId, hostPath, containerPath });
export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) =>
invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir });
+14
View File
@@ -37,6 +37,7 @@ export interface Project {
claude_instructions: string | null;
enabled_mcp_servers: string[];
claude_code_settings: ClaudeCodeSettings | null;
renamed_session_names: Record<string, string>;
created_at: string;
updated_at: string;
}
@@ -116,6 +117,17 @@ export interface GlobalAwsSettings {
aws_config_path: string | null;
aws_profile: string | null;
aws_region: string | null;
default_model_id: string | null;
}
export interface GlobalOllamaSettings {
base_url: string | null;
default_model_id: string | null;
}
export interface GlobalOpenAiCompatibleSettings {
base_url: string | null;
default_model_id: string | null;
}
export interface AppSettings {
@@ -126,6 +138,8 @@ export interface AppSettings {
image_source: ImageSource;
custom_image_name: string | null;
global_aws: GlobalAwsSettings;
global_ollama: GlobalOllamaSettings;
global_openai_compatible: GlobalOpenAiCompatibleSettings;
global_claude_instructions: string | null;
global_custom_env_vars: EnvVar[];
auto_check_updates: boolean;
+48
View File
@@ -1,6 +1,24 @@
import { create } from "zustand";
import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer } from "../lib/types";
const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed";
function loadSidebarCollapsed(): boolean {
try {
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
} catch {
return false;
}
}
function persistSidebarCollapsed(value: boolean) {
try {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, value ? "1" : "0");
} catch {
// ignore — storage may be unavailable
}
}
interface AppState {
// Projects
projects: Project[];
@@ -26,8 +44,21 @@ interface AppState {
// UI state
terminalHasSelection: boolean;
setTerminalHasSelection: (has: boolean) => void;
// STT toggle for the active session, registered by App so the terminal's
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
sttToggle: () => void;
setSttToggle: (fn: () => void) => void;
// Active terminal scroll state, surfaced so the status bar can host the
// "Jump to Current" control. Only the active TerminalView writes these.
terminalAtBottom: boolean;
setTerminalAtBottom: (v: boolean) => void;
scrollActiveToBottom: () => void;
setScrollActiveToBottom: (fn: () => void) => void;
sidebarView: "projects" | "mcp" | "settings";
setSidebarView: (view: "projects" | "mcp" | "settings") => void;
sidebarCollapsed: boolean;
setSidebarCollapsed: (collapsed: boolean) => void;
toggleSidebarCollapsed: () => void;
dockerAvailable: boolean | null;
setDockerAvailable: (available: boolean | null) => void;
imageExists: boolean | null;
@@ -104,8 +135,25 @@ export const useAppState = create<AppState>((set) => ({
// UI state
terminalHasSelection: false,
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
sttToggle: () => {},
setSttToggle: (fn) => set({ sttToggle: fn }),
terminalAtBottom: true,
setTerminalAtBottom: (v) => set({ terminalAtBottom: v }),
scrollActiveToBottom: () => {},
setScrollActiveToBottom: (fn) => set({ scrollActiveToBottom: fn }),
sidebarView: "projects",
setSidebarView: (view) => set({ sidebarView: view }),
sidebarCollapsed: loadSidebarCollapsed(),
setSidebarCollapsed: (collapsed) => {
persistSidebarCollapsed(collapsed);
set({ sidebarCollapsed: collapsed });
},
toggleSidebarCollapsed: () =>
set((state) => {
const next = !state.sidebarCollapsed;
persistSidebarCollapsed(next);
return { sidebarCollapsed: next };
}),
dockerAvailable: null,
setDockerAvailable: (available) => set({ dockerAvailable: available }),
imageExists: null,
+26 -3
View File
@@ -212,10 +212,14 @@ if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then
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.
# When set (Bedrock + profile/SSO auth), inject awsAuthRefresh into
# ~/.claude.json so Claude Code calls triple-c-sso-refresh when AWS credentials
# expire mid-session. When NOT set, strip any awsAuthRefresh left behind by a
# previous Bedrock-profile session — ~/.claude.json lives in the persisted home
# volume, so without this the container keeps trying to run the SSO refresh even
# after switching to a non-SSO backend (Anthropic/Ollama) or to static creds.
CLAUDE_JSON="/home/claude/.claude.json"
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
@@ -227,6 +231,15 @@ if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON"
unset AWS_SSO_AUTH_REFRESH_CMD
elif [ -f "$CLAUDE_JSON" ] && grep -q '"awsAuthRefresh"' "$CLAUDE_JSON" 2>/dev/null; then
# Only rewrite when the key is actually present, to avoid a needless jq
# reformat of ~/.claude.json on every start of a non-SSO backend.
MERGED=$(jq 'del(.awsAuthRefresh)' "$CLAUDE_JSON" 2>/dev/null)
if [ -n "$MERGED" ]; then
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON"
fi
fi
# ── Docker socket permissions ────────────────────────────────────────────────
@@ -292,6 +305,16 @@ if ls "$SCHEDULER_DIR/tasks/"*.json >/dev/null 2>&1; then
echo "entrypoint: restored crontab from persisted tasks"
fi
# ── Claude Code self-update ──────────────────────────────────────────────────
# Update the Claude Code CLI to the latest version on container start, before
# any terminal session launches `claude`. Runs as the claude user (the CLI is
# installed under /home/claude/.claude/bin). Non-fatal and time-bounded so a
# slow or offline network never blocks container readiness.
echo "entrypoint: checking for Claude Code updates..."
timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/home/claude/.local/bin:$PATH"; claude update' \
&& echo "entrypoint: Claude Code is up to date" \
|| echo "entrypoint: warning — Claude Code update skipped or failed (continuing)"
# ── Stay alive as claude ─────────────────────────────────────────────────────
echo "Triple-C container ready."
exec su -s /bin/bash claude -c "exec sleep infinity"
@@ -1,11 +1,11 @@
---
name: agentic-workflow
description: Active orchestrator for multi-agent flight execution. Drives the full leg cycle (design, implement, review, commit) using three separate Claude instances.
description: Active orchestrator for multi-agent flight execution. Drives leg design per leg, then batches implementation across all autonomous legs, with a single code review and commit at the end of the flight.
---
# Agentic Workflow
Orchestrate multi-agent flight execution. You drive the full leg cycle — designing legs, spawning Developer and Reviewer agents, and managing git workflow — for a target project's flight.
Orchestrate multi-agent flight execution. You drive the full leg cycle — designing legs, spawning Developer and Reviewer agents, and managing git workflow — for a target project's flight. Leg design is reviewed per leg, but code review and commit are deferred until after the last autonomous leg completes. This eliminates per-leg review/commit overhead while keeping the same leg design and implementation structure.
## Prerequisites
@@ -33,8 +33,6 @@ Example: `/agentic-workflow flight 03 for epipen mission 04`
6. **Read the flight log** — ground truth from prior execution
7. **Count total legs** from the flight spec — track progress throughout
8. **Determine starting point** — which leg is next based on flight log and leg statuses
9. **Read git strategy** from `{target-project}/.flightops/ARTIFACTS.md` `## Git Workflow` section. Default to `branch` if the section is absent.
10. **Set `{working-directory}`**`branch`: the target project root; `worktree`: the worktree path (see Git Workflow section below)
**Mark flight as in-flight**: After loading the flight artifact, if the flight status is `ready`, update it to `in-flight` before proceeding. If already `in-flight`, leave it as-is.
@@ -46,13 +44,15 @@ If resuming a flight already in progress, verify state consistency:
Repeat for each leg in the flight.
**Mid-execution scope changes**: if the work in this flight stops serving its original purpose (operator pivots, prior assumptions invalidated), don't rewrite the mission/flight artifacts in place. Preserve the original framing as commentary, record the pivot decision in the flight-log Flight Director Notes with rationale, and treat the new framing as the live spec going forward. If the pivot supersedes content in an upstream artifact (maintenance report, prior debrief), annotate at the artifact header rather than rewriting the body — inspection records are snapshots, not living plans.
### 2a: Leg Design
1. **Design the leg** using the `/leg` skill (if the Skill tool is unavailable, read `.claude/skills/leg/SKILL.md` and follow the workflow directly)
- Read the flight spec, flight log, and relevant source code
- Create the leg artifact with acceptance criteria
2. **Spawn a Developer agent for design review** (Task tool, `subagent_type: "general-purpose"`)
- Working directory: `{working-directory}`
- Working directory: `{target-project}`
- Provide the "Review Leg Design" prompt from the leg-execution phase file's Prompts section
- The Developer reads the leg artifact and cross-references against actual codebase state
- The Developer provides a structured assessment: approve, approve with changes, or needs rework
@@ -71,41 +71,42 @@ Repeat for each leg in the flight.
**NEVER implement code directly.** Spawn a Developer agent via the Task tool.
**Interactive/UAT legs**: If the leg is a UAT, alignment, or other interactive leg (identified by slug like `uat-*`, `alignment-*`, or explicit marking in the flight spec), do NOT spawn agents to execute it autonomously. The human performs verification — the Flight Director guides them through it:
**Interactive/HAT legs**: If the leg is a HAT (human acceptance test), alignment, or other interactive leg (identified by slug like `hat-*`, `alignment-*`, or explicit marking in the flight spec), do NOT spawn agents to execute it autonomously. The human performs verification — the Flight Director guides them through it:
1. **Design the leg** normally (2a), but keep it lightweight — the acceptance criteria are verification steps, not implementation tasks
2. **Skip the autonomous implementation cycle** (no Developer/Reviewer agents)
3. **Guide the human through verification steps one at a time** — present a single step, wait for the human to perform it and report results, then proceed to the next step
4. **Fix issues inline** — if the human reports a failure, diagnose and fix it (spawning a Developer agent if code changes are needed), then re-verify that step before moving on
5. **Commit when all steps pass** spawn a Developer agent to update artifacts and commit
5. **Commit when all steps pass** — update artifacts and commit
**Standard (autonomous) legs**: Follow the Developer/Reviewer cycle below.
**Standard (autonomous) legs**: Spawn a Developer agent — but do NOT review or commit after each leg.
1. **Spawn a Developer agent** (Task tool, `subagent_type: "general-purpose"`)
- Working directory: `{working-directory}`
- Working directory: `{target-project}`
- Provide the "Implement" prompt from the leg-execution phase file's Prompts section
- The Developer updates leg status to `in-flight`, implements to acceptance criteria
- When done, the Developer updates leg status to `landed`, updates flight log, and signals `[HANDOFF:review-needed]` — do NOT let it commit
2. **Spawn a Reviewer agent** (Task tool, `subagent_type: "general-purpose"`)
- Working directory: `{working-directory}`
- Provide the "Review" prompt from the leg-execution phase file's Prompts section
- The Reviewer evaluates ALL uncommitted changes against acceptance criteria and code quality
- The Reviewer signals `[HANDOFF:confirmed]` or lists issues with severity
3. **If issues found**, spawn a new Developer agent to fix them
- Provide the "Fix Review Issues" prompt from the leg-execution phase file with the Reviewer's feedback
- Loop review/fix until the Reviewer confirms
4. **Spawn the Developer agent to commit** after review passes
- Provide the "Commit" prompt from the leg-execution phase file's Prompts section
- The commit must include code changes, updated flight log, and leg status updated to `completed`
- When done, the Developer updates leg status to `landed` and updates flight log — do NOT let it commit or signal `[HANDOFF:review-needed]`
### 2c: Leg Transition
After `[COMPLETE:leg]` (all git/PR operations run from `{working-directory}`):
After the Developer completes a leg:
1. Increment `legs_completed`
2. **Manage PR**:
- **First leg**: Open a draft PR with the leg checklist in the body (see PR Body Format below), then check off the completed leg
- **Subsequent legs**: Use `gh pr edit --body` to check off the newly completed leg in the existing PR body
3. If more legs remain → return to 2a
4. If all legs complete → proceed to Phase 3
2. If more autonomous legs remain → return to 2a
3. If this was the last autonomous leg → proceed to Phase 2d
### 2d: Flight Review and Commit
After all autonomous legs are implemented (all uncommitted):
1. **Spawn a Reviewer agent** (Task tool, `subagent_type: "general-purpose"`)
- Working directory: `{target-project}`
- Provide the "Review" prompt from the leg-execution phase file's Prompts section
- The Reviewer evaluates ALL uncommitted changes against acceptance criteria and code quality
- The Reviewer signals `[HANDOFF:confirmed]` or lists issues with severity
2. **If issues found**, spawn a new Developer agent to fix them
- Provide the "Fix Review Issues" prompt from the leg-execution phase file with the Reviewer's feedback
- Loop review/fix until the Reviewer confirms
3. **Commit** after review passes — include all code changes, updated flight log, and all leg statuses updated to `completed`
4. **Manage PR**: Open a draft PR with the leg checklist in the body (see PR Body Format below), all legs checked off
## Phase 3: Flight Completion
@@ -114,8 +115,7 @@ After `[COMPLETE:leg]` (all git/PR operations run from `{working-directory}`):
3. **Verify documentation** — check that CLAUDE.md, README, and other project docs reflect any new commands, endpoints, configuration, or APIs introduced during the flight. If not, spawn a Developer agent to update them.
4. **Update flight status** to `landed`
5. **Check off flight** in mission artifact
6. **Clean up worktree** (worktree strategy only) — run `git worktree remove` after the PR is marked ready for review
7. **Signal `[COMPLETE:flight]`**
6. **Signal `[COMPLETE:flight]`**
The flight debrief is a separate step run via `/flight-debrief` after the flight lands. The debrief transitions the flight to `completed`.
@@ -141,33 +141,20 @@ Signals are part of the Flight Control methodology and are NOT configurable per-
## Flight Director Decision Log
The Flight Director must maintain transparency about its own decisions. After each major orchestration step, log what happened and why in the flight log under a `### Flight Director Notes` subsection:
1. **Phase file loading** — Record which phase file was loaded (project or default fallback) and what crew was extracted
2. **Agent spawning** — Record which agent was spawned, with what prompt, and what model
3. **Review cycle decisions** — When incorporating feedback, note what was accepted/rejected and why
4. **Escalation decisions** — When choosing between "fix and re-review" vs "escalate to human," note the reasoning
5. **Signal interpretation** — When a crew agent's output is ambiguous, note how it was interpreted
This is not a separate file — it goes in the flight log alongside leg entries. The goal is that anyone reviewing the flight log can understand not just what the crew did, but why the Flight Director made the orchestration choices it did.
Log orchestration decisions in the flight log under `### Flight Director Notes` — phase file loaded, agents spawned, review-cycle calls, escalations, signal interpretations. Anyone reading the log should understand not just what the crew did but why MC made the choices it did.
## Git Workflow
### Strategy Selection
Read the `## Git Workflow` section from `{target-project}/.flightops/ARTIFACTS.md`. The `Strategy` property determines which workflow to use. If the section is absent, default to `branch`.
### Shared Elements
Both strategies use the same branch naming, commit format, PR lifecycle, and PR body format.
All agents work in the target project root on a feature branch created at flight start.
**Branch naming**: `flight/{number}-{slug}`
**Flight start**: `git checkout -b flight/{number}-{slug}`
**Commit message format:**
```
leg/{number}: {description}
flight/{number}: {description}
Flight: {flight-number}
Mission: {mission-number}
```
@@ -175,8 +162,7 @@ Mission: {mission-number}
| Event | Action |
|-------|--------|
| First leg complete | Open draft PR with leg checklist in body |
| Each leg complete | Commit code + artifacts, update PR checklist |
| All legs complete | Open draft PR with all legs checked off |
| Flight landed | Mark PR ready for review |
**PR body format:**
@@ -190,35 +176,10 @@ Mission: {mission-number}
## Legs
- [ ] `{leg-slug}` — {brief description}
- [ ] `{leg-slug}` — {brief description}
- [x] `{leg-slug}` — {brief description}
- [x] `{leg-slug}` — {brief description}
```
### Strategy: Branch
The default single-checkout workflow. One flight at a time per working copy.
| Step | Command |
|------|---------|
| Flight start | `git checkout -b flight/{number}-{slug}` |
| Set `{working-directory}` | Target project root |
| Agents work in | Project root |
| Flight landed | PR marked ready for review |
### Strategy: Worktree
Worktree isolation enables parallel flights on a single repo clone.
| Step | Command |
|------|---------|
| Flight start | `git worktree add .worktrees/flight-{number}-{slug} -b flight/{number}-{slug}` |
| Set `{working-directory}` | `.worktrees/flight-{number}-{slug}` |
| Orchestrator stays on | Main branch (does not checkout the flight branch) |
| Agents work in | Worktree path |
| Flight landed | PR marked ready for review, then `git worktree remove .worktrees/flight-{number}-{slug}` |
**Note:** The `.worktrees/` directory must be in `.gitignore` when using this strategy.
## Error Handling
| Situation | Action |
@@ -229,5 +190,4 @@ Worktree isolation enables parallel flights on a single repo clone.
| Leg marked aborted | Escalate to human with abort details |
| Artifact discrepancy | Remediate before proceeding |
| Off the rails | Roll back to last leg commit, escalate |
| Stale worktree (worktree strategy) | Run `git worktree prune`, recreate if needed |
| Agent hangs on tests | Kill the agent, spawn new Developer to isolate and fix hanging tests |
@@ -52,6 +52,7 @@ Read `{target-project}/.flightops/agent-crews/flight-debrief.md` for crew defini
1. **Spawn a Developer agent** in the target project context (Task tool, `subagent_type: "general-purpose"`)
- Provide the "Debrief Interview" prompt from the flight-debrief phase file's Prompts section
- The Developer examines code changes, test coverage, patterns, and technical debt
- In addition to the crew prompt, the Flight Director directly instructs the Developer to: run the full test suite once, capture per-suite wall-clock timing, pass/fail counts (with names of any failing tests), skipped/ignored counts, and any flakes observed on the run; then read prior flight debriefs in the project and compare current metrics against any earlier numbers they find. Report findings as narrative — naming suites, quoting deltas, and proposing likely causes when visible from this flight's changes.
- The Developer provides structured debrief input
#### Architect Interview
@@ -93,6 +94,12 @@ Synthesize Developer input, Architect input, human input, and document analysis
- What technical debt was introduced?
- Does implementation align with project conventions?
#### Test Metrics Capture
- Run the full test suite as part of the debrief and record metrics: per-suite wall-clock timing, pass/fail counts, skipped/ignored tests, and any flakes observed on this run.
- Read prior flight debriefs in this project for earlier metrics observations. Compare current numbers against whatever priors exist.
- Surface meaningful changes — significant slowdowns, new failures, growing skip lists, recurring flakes — as narrative observations in whichever existing section best fits (Technical, Key Learnings, or What Could Be Improved).
- If this is the first flight to capture metrics, the numbers seed comparisons for future flights.
#### Deviation Analysis
- What deviations occurred and why?
- Were deviations captured in the flight log?
@@ -37,6 +37,49 @@ Create a technical flight spec from a mission.
- What's been completed vs. in progress?
- Are there dependencies on other flights?
6. **Review recent flight debriefs for relevant observations**
- Read recent flight debriefs in the project (within and adjacent to this mission)
- Look for test metrics observations: known slow suites, recurring flakes, persistent failures, growing skip lists — anything narrated by prior debriefs
- Look for unresolved technical concerns or recommendations that touch this flight's likely scope
- Carry forward into the technical approach and risk picture: e.g. "this area has slow integration tests, account for that in time estimates"; "known flake in suite X, plan to fix or quarantine if touching that area"; "prior debrief flagged Y — verify whether still relevant"
### Phase 1b: Upstream Reconnaissance
**Applies when**: The flight sources work items from a prior artifact that cites specific code locations — a maintenance report, a flight or mission debrief that enumerates outstanding follow-ups, an issue tracker, or a security audit. Skip this phase for greenfield flights where no source artifact pre-enumerates findings.
Source artifacts go stale. Items cited weeks or even days ago may have been incidentally fixed by intervening flights, partially addressed, or the cited file/line may have moved. Without a recon pass, stale items get drafted into legs and only get caught at design review or implementation — wasted artifact churn and rework.
**Goal**: Before designing legs, walk every cited item against current code and classify it.
1. **Enumerate source items**
- Read the source artifact in full and identify every item it treats as outstanding, actionable work — regardless of how the artifact organizes them. Different projects use different headings, severity labels, and status conventions; identify items by intent, not by literal section name.
- Capture each item's cited file paths, line numbers, and the change it describes
2. **Verify each item against current code**
- Read the cited locations (or grep for the cited symbols if line numbers have drifted)
- Determine whether the described gap still exists
3. **Classify each item** into one of:
- **`confirmed-live`** — gap still exists, item is real work
- **`already-satisfied`** — code now reflects what the item asked for; recommend retiring
- **`partially-satisfied`** — some sub-points done, others not; scope down
- **`needs-human-recheck`** — cannot be verified mechanically (runtime state, external system, credentials, infrastructure that isn't in the repo)
- **`drifted`** — cited location moved or symbol renamed; needs re-locating before classification
4. **Produce a Reconnaissance Report**
- Append the report to the flight log under a clearly-titled section (something like `## Reconnaissance Report` if the project's flight-log conventions don't dictate otherwise)
- One row per source item: `{item-id} | {classification} | {evidence: file:line or "cannot verify from repo"} | {recommendation}`
- For `already-satisfied`: cite the specific code that satisfies the item, so the user can audit your call
5. **Default to flag-for-human, not auto-retire**
- Do NOT silently drop items classified as `already-satisfied` or `partially-satisfied`
- Present the recon report to the user before proceeding to Phase 2 and ask: "Confirm retirements / accept partial scope / override any classifications?"
- The user has authority to keep an item live even if you think it's satisfied (e.g., the satisfying code is incomplete in ways you can't see)
6. **Carry retired items into the flight artifact**
- In the section of the flight artifact the project uses to enumerate scope items contributing to mission criteria, retired items appear as completed `[x]` entries with the satisfying evidence inline — they are NOT silently dropped from the spec
- This preserves traceability: a future reader can see all source items were considered, and which ones were judged already-satisfied during reconnaissance
### Phase 2: Code Interrogation
Explore the target project's codebase to inform the technical approach:
@@ -158,9 +201,11 @@ Break flights into legs based on technical boundaries:
**For documentation**: Consider whether README, CLAUDE.md, or other docs need updates as part of this flight — especially for flights adding new CLI commands, API endpoints, or configuration options
**For carry-forward debt from prior debriefs**: when multiple small items touch the same files or directories, bundle them into a single shared-surface leg rather than scheduling separate flights or letting them decay. Items that don't share a surface stay separate — bundling unrelated work just to "pull forward" is how scope creep enters maintenance work.
**For schema changes**: Include explicit migration legs and verify against the live database, not just mocks
**For UAT and alignment**: During the crew interview, ask the user whether they'd like to include a UAT and alignment leg. Explain that this optional leg is a guided UAT session — the agent walks the user through a series of tests and verification steps, fixing issues along the way until the user is satisfied with the results. If the user opts in, include it in the breakdown, marked as optional.
**For HAT and alignment**: During the crew interview, ask the user whether they'd like to include an interactive HAT (human acceptance test) and alignment leg. Explain that this optional leg is a guided HAT session — the agent walks the user through a series of tests and verification steps, fixing issues along the way until the user is satisfied with the results. If the user opts in, include it in the breakdown, marked as optional.
### Pre-Flight Rigor
@@ -33,7 +33,7 @@ Check for legacy directory layouts and offer to migrate them.
2. **Run detection checks** for each migration in order (001, 002, ...)
3. **If no migrations are needed**, proceed silently to the next step
4. **If any migrations are needed**, present a summary to the user:
> "Detected legacy directory layout in {project}. The following migrations are available:"
> "Detected legacy directory layout in {target-project}. The following migrations are available:"
>
> - _Each applicable migration's user message_
>
@@ -61,13 +61,13 @@ The script outputs one of:
Based on the status:
**If `missing`**:
> "Flight operations directory not found. Create `{project}/.flightops/` with methodology references?"
> "Flight operations directory not found. Create `{target-project}/.flightops/` with methodology references?"
**If `outdated`**:
> "Flight operations references in {project} are outdated. Update?"
> "Flight operations references in {target-project} are outdated. Update?"
**If `current`**:
> "Flight operations references are up-to-date in {project}."
> "Flight operations references are up-to-date in {target-project}."
If the user confirms, create/update the directory:
@@ -79,34 +79,13 @@ cp "${SKILL_DIR}/README.md" "{target-project}/.flightops/"
### 5. Configure Artifact System (New Projects Only)
**Only if ARTIFACTS.md doesn't exist**, ask the user to select an artifact system:
> "How should mission, flight, and leg artifacts be stored?"
Available templates:
- **files** — Markdown files in the repository (`templates/ARTIFACTS-files.md`)
- **jira** — Jira issues: Epics, Stories, Sub-tasks (`templates/ARTIFACTS-jira.md`)
#### 5a. Check for Setup Questions
After the user selects a template, read the template file and check if it contains a `## Setup Questions` section with a table of questions.
If setup questions exist:
1. Parse the questions from the table (first column contains the questions)
2. Ask the user each question interactively
3. Replace the placeholder answers in the table with the user's responses
#### 5b. Copy and Populate Template
Copy the selected template, with answers populated if setup questions were asked:
**Only if ARTIFACTS.md doesn't exist**, copy the template:
```bash
cp "${SKILL_DIR}/templates/ARTIFACTS-{selection}.md" \
cp "${SKILL_DIR}/templates/ARTIFACTS-files.md" \
"{target-project}/.flightops/ARTIFACTS.md"
```
If setup questions were answered, update the ARTIFACTS.md file to replace the placeholder answers with the user's responses.
**If ARTIFACTS.md already exists**, do not modify it — it's project-specific and may have been customized.
### 6. Configure Project Crew
@@ -166,7 +145,7 @@ This project uses [Flight Control](https://github.com/msieurthenardier/mission-c
After creating or updating the directory, inform the user:
> "If you have Claude Code running in {project}, restart it to pick up the new flight operations references."
> "If you have Claude Code running in {target-project}, restart it to pick up the new flight operations references."
This ensures Claude Code loads the new files into its context when working in the target project.
@@ -175,7 +154,7 @@ This ensures Claude Code loads the new files into its context when working in th
This skill creates/updates the following at project root:
```
{project}/
{target-project}/
├── CLAUDE.md # Updated with Flight Operations section
└── .flightops/ # Hidden directory for Flight Control
├── README.md # Explains the directory purpose
@@ -6,7 +6,7 @@ human and project-side agents to capture both execution and design perspectives.
## Crew
### Developer
- **Context**: {project}/
- **Context**: {target-project}/
- **Model**: Sonnet
- **Role**: Provides developer perspective on flight execution. Reviews what was
built, identifies technical debt introduced, evaluates implementation quality,
@@ -14,7 +14,7 @@ human and project-side agents to capture both execution and design perspectives.
- **Actions**: debrief-interview
### Architect
- **Context**: {project}/
- **Context**: {target-project}/
- **Model**: Sonnet
- **Role**: Closes the design feedback loop. Evaluates whether the design decisions
made during flight planning held up in practice. Reviews architectural impact of
@@ -6,7 +6,7 @@ technical spec and uses project-side agents to validate against the real codebas
## Crew
### Architect
- **Context**: {project}/
- **Context**: {target-project}/
- **Model**: Sonnet
- **Role**: Reviews flight specs for technical soundness. Validates design
decisions, prerequisites, technical approach, and leg breakdown against
@@ -56,6 +56,20 @@ Evaluate:
5. Codebase state — does the spec account for current working tree, existing tooling,
and conventions that might affect implementation?
6. Architecture — does the approach maintain or improve system structure?
7. State-machine reachability — for every state, status, or lifecycle value the flight
introduces or relies on (e.g. "agent_deleted", "draft", "queued"), audit which
infrastructure layers could foreclose it: DB constraints (FK ON DELETE behaviors,
NOT NULL, CHECK), application caches and their invalidation rules, API/protocol
versions, fallback handlers that mask the state, and existing tests that pin
contradictory behavior. A state that the schema or a cache can silently prevent
is a design hole, not an implementation detail.
8. Cache freshness contracts — for every cache (in-memory dict, query result cache,
derived state, frontend session storage) the flight reads from or populates,
the design must declare source of truth, rebuild trigger (per-call / TTL /
invalidation event / accepted permanent staleness), maximum staleness, and which
user actions should invalidate it. Vague answers ("eventually", "on next cycle")
without a concrete trigger are a flag. Conflating "cached object works" with
"cached object reflects current source" is a common category error worth catching.
Provide structured output:
@@ -7,21 +7,21 @@ The Flight Director (Mission Control) orchestrates this phase using the
## Crew
### Developer
- **Context**: {working-directory}/
- **Context**: {target-project}/
- **Model**: Sonnet
- **Role**: Implements code changes. Also performs design reviews against real
codebase to validate leg specs before implementation.
- **Actions**: implement, fix-review-issues, commit, review-leg-design
### Reviewer
- **Context**: {working-directory}/
- **Context**: {target-project}/
- **Model**: Sonnet (NEVER Opus)
- **Role**: Reviews code changes for quality, correctness, and criteria compliance.
Has NO knowledge of Developer's reasoning — only sees resulting changes.
- **Actions**: review
### Accessibility Reviewer (optional)
- **Context**: {working-directory}/
- **Context**: {target-project}/
- **Model**: Sonnet
- **Enabled**: false
- **Role**: Reviews UI changes for accessibility compliance. Evaluates against
@@ -72,7 +72,6 @@ The Flight Director substitutes these variables in prompts at runtime:
| `{flight-number}` | Current flight number | All prompts |
| `{leg-number}` | Current leg number | Leg-scoped prompts |
| `{leg-artifact-path}` | Path to the leg artifact file | review-leg-design |
| `{working-directory}` | Resolved working directory for the agent (project root for branch strategy, worktree path for worktree strategy) | All prompts |
| `{reviewer-issues}` | Full text of reviewer feedback (dynamic) | fix-review-issues |
## Prompts
@@ -6,7 +6,7 @@ both the human and a project-side Architect to capture strategic technical persp
## Crew
### Architect
- **Context**: {project}/
- **Context**: {target-project}/
- **Model**: Sonnet
- **Role**: Provides architectural perspective on mission outcomes. Evaluates
whether the system evolved well across flights, identifies structural issues,
@@ -6,7 +6,7 @@ and uses project-side agents to validate technical viability.
## Crew
### Architect
- **Context**: {project}/
- **Context**: {target-project}/
- **Model**: Sonnet
- **Role**: Validates technical viability of proposed outcomes. Ensures business
goals align with what's actually possible given the codebase, stack, and
@@ -7,7 +7,7 @@ for severity assessment and roundtable moderation.
## Crew
### Inspector
- **Context**: {project}/
- **Context**: {target-project}/
- **Model**: Sonnet
- **Role**: Performs broad read-only codebase inspection across all applicable
categories. Runs test suites, linters, type checkers, audit commands, and
@@ -15,7 +15,7 @@ for severity assessment and roundtable moderation.
- **Actions**: inspect-codebase
### Security Reviewer
- **Context**: {project}/
- **Context**: {target-project}/
- **Model**: Sonnet
- **Role**: Performs focused manual security review of authentication flows,
injection surfaces, secrets handling, CORS/CSP configuration, and data
@@ -24,7 +24,7 @@ for severity assessment and roundtable moderation.
- **Actions**: review-security
### CI/CD Reviewer (optional)
- **Context**: {project}/
- **Context**: {target-project}/
- **Model**: Sonnet
- **Enabled**: false (enable when project has CI/CD pipelines)
- **Role**: Reviews CI/CD pipeline configuration, build security, deployment
@@ -33,7 +33,7 @@ for severity assessment and roundtable moderation.
- **Actions**: review-cicd
### Accessibility Reviewer (optional)
- **Context**: {project}/
- **Context**: {target-project}/
- **Model**: Sonnet
- **Enabled**: false (enable when project has user-facing UI)
- **Role**: Reviews codebase for accessibility compliance against WCAG 2.1 AA
@@ -42,7 +42,7 @@ for severity assessment and roundtable moderation.
- **Actions**: review-accessibility
### Architect
- **Context**: {project}/
- **Context**: {target-project}/
- **Model**: Opus
- **Role**: Reviews all reviewer findings alongside debrief context. Assigns
severity per finding, challenges questionable assessments, moderates
@@ -463,6 +463,7 @@ For each finding, assign one of:
- Does this finding represent a real risk, or is it noise?
- Is the severity proportional to the actual impact?
- Would this compound if left for another cycle?
- Is the infrastructure or framing this finding pertains to still serving its original purpose, or has it drifted into "maybe-someday" territory?
- Is this a new discovery or previously acknowledged debt?
- Do multiple reviewers corroborate the same issue?
- Are any reviewer assessments questionable — too alarmist or too dismissive?
@@ -5,7 +5,7 @@ This project stores Flight Control artifacts as markdown files in the repository
## Directory Structure
```
{project}/
{target-project}/
├── missions/
│ └── {NN}-{mission-slug}/
│ ├── mission.md
@@ -159,7 +159,7 @@ How the objective will be achieved.
- [ ] `{leg-slug}` - {Brief description}
- [ ] `{leg-slug}` - {Brief description}
- [ ] `uat-and-alignment` *(optional)* - Guided UAT session with iterative fixes
- [ ] `hat-and-alignment` *(optional)* - Guided HAT (human acceptance test) session with iterative fixes
---
@@ -560,16 +560,3 @@ States are tracked in the frontmatter or status field of each artifact:
- **Flight briefings**: Created before execution, not modified after
- **Debriefs**: Created after completion, may be updated with follow-up notes
- **Mission as briefing**: The mission.md document serves as both definition and briefing (no separate mission-briefing.md)
## Git Workflow
| Property | Value |
|----------|-------|
| Strategy | `branch` |
**Options:**
- **`branch`** (default) — Single-checkout workflow. The orchestrator creates a feature branch and all agents work in the project root. One flight at a time per working copy.
- **`worktree`** — Worktree isolation. The orchestrator creates a git worktree under `.worktrees/` for each flight. Agents work in the worktree path. Parallel flights are possible on a single repo clone.
When using the `worktree` strategy, add `.worktrees/` to `.gitignore`.
@@ -1,408 +0,0 @@
# Artifact System: Jira
This project stores Flight Control artifacts as Jira issues.
## Issue Type Mapping
| Flight Control | Jira Issue Type | Hierarchy |
|----------------|-----------------|-----------|
| Mission | Epic | Parent |
| Flight | Story | Child of Epic |
| Leg | Sub-task | Child of Story |
## Setup Questions
Answer these questions when configuring Jira artifacts for your project:
| Question | Answer |
|----------|--------|
| What is the Jira project key? | `PROJECT` |
| JQL query for discovering flight documentation? | (e.g., `project = PROJECT AND labels = flight-control`) |
## Configuration
| Property | Value |
|----------|-------|
| Project Key | `PROJECT` |
| Board | (specify board name or ID) |
| Labels | `flight-control` |
---
## Custom Fields
<!-- Add your project's custom Jira fields here -->
| Custom Field | Jira Field ID | Required | Used For | Notes |
|--------------|---------------|----------|----------|-------|
| (example) Team | `customfield_10001` | Yes | All issues | Select from predefined teams |
| (example) Sprint | `customfield_10002` | No | Stories, Sub-tasks | Assign to sprint |
## Project Rules
<!-- Document project-specific Jira rules and conventions here -->
### Required Fields by Issue Type
**Epic (Mission):**
- (list required fields for your project)
**Story (Flight):**
- (list required fields for your project)
**Sub-task (Leg):**
- (list required fields for your project)
### Workflow Rules
- (document any workflow restrictions or automation rules)
- (e.g., "Stories cannot move to In Progress without Epic Link")
### Naming Conventions
- (document any naming patterns required by your project)
- (e.g., "Epic summaries must start with [MISSION]")
---
## Core Artifacts
### Mission → Epic
| Field | Mapping |
|-------|---------|
| Summary | Mission title |
| Description | See format below |
| Labels | `flight-control`, `mission` |
**Description Format:**
```
## Outcome
{What success looks like in human terms}
## Context
{Why this mission matters now}
## Success Criteria
- [ ] {Criterion 1}
- [ ] {Criterion 2}
## Stakeholders
{Who cares about this outcome}
## Constraints
{Non-negotiable boundaries}
## Environment Requirements
{Development and runtime requirements}
## Open Questions
{Unknowns needing resolution}
## Known Issues
Emergent blockers and issues discovered during execution. Add items here as flights surface problems that affect the broader mission — things not anticipated during planning but visible at the mission level.
- [ ] {Issue description} — discovered in Flight {N}, affects {scope}
## Flights
> **Note:** These are tentative suggestions, not commitments. Flights are planned and created one at a time as work progresses. This list will evolve based on discoveries during implementation.
- [ ] Flight 1: {description}
```
---
### Flight → Story
| Field | Mapping |
|-------|---------|
| Summary | Flight title |
| Description | See format below |
| Epic Link | Parent mission epic |
| Labels | `flight-control`, `flight` |
**Description Format:**
```
## Objective
{What this flight accomplishes}
## Contributing to Criteria
- {Relevant success criterion 1}
- {Relevant success criterion 2}
## Design Decisions
{Key technical decisions and rationale}
## Prerequisites
- [ ] {What must be true before execution}
## Technical Approach
{How the objective will be achieved}
## Legs
> **Note:** These are tentative suggestions, not commitments. Legs are planned and created one at a time as the flight progresses. This list will evolve based on discoveries during implementation.
- [ ] {leg-slug} - {description}
## Validation Approach
{How will this flight be validated? Manual testing, automated tests, or both?}
## Verification
{How to confirm success}
```
---
### Leg → Sub-task
| Field | Mapping |
|-------|---------|
| Summary | Leg title |
| Description | See format below |
| Parent | Flight story |
| Labels | `flight-control`, `leg` |
**Description Format:**
```
## Objective
{Single sentence: what this leg accomplishes}
## Context
{Design decisions and learnings from prior legs}
## Inputs
{What must exist before this leg runs}
## Outputs
{What exists after completion}
## Acceptance Criteria
- [ ] {Criterion 1}
- [ ] {Criterion 2}
## Verification Steps
How to confirm each criterion is met:
- {Command or manual check for criterion 1}
- {Command or manual check for criterion 2}
## Implementation Guidance
{Step-by-step guidance}
## Files Affected
{List of files to modify}
```
---
## Supporting Artifacts
### Flight Log → Story Comments
| Property | Value |
|----------|-------|
| Location | Comments on the Flight (Story) |
| Format | Timestamped comments with prefix |
| Update pattern | Append new comments during execution |
**Comment Format:**
```
[Flight Log] {YYYY-MM-DD HH:MM}
## {Entry Type}: {Title}
{Content based on entry type - see below}
```
**Entry Types:**
- `Leg Progress` - Status updates for leg completion
- `Decision` - Runtime decisions not in original plan
- `Deviation` - Departures from planned approach
- `Anomaly` - Unexpected issues encountered
- `Session Notes` - General progress notes
---
### Flight Briefing → Story Comment
| Property | Value |
|----------|-------|
| Location | Comment on the Flight (Story) |
| Created | Before flight execution begins |
| Label | `[Flight Briefing]` |
**Comment Format:**
```
[Flight Briefing] {YYYY-MM-DD}
## Mission Context
{How this flight contributes to mission}
## Objective
{What this flight will accomplish}
## Key Decisions
{Critical decisions crew should know}
## Risks
| Risk | Mitigation |
|------|------------|
| {risk} | {mitigation} |
## Legs Overview
1. {leg} - {description}
2. {leg} - {description}
## Success Criteria
{How we'll know the flight succeeded}
```
---
### Flight Debrief → Story Comment
| Property | Value |
|----------|-------|
| Location | Comment on the Flight (Story) |
| Created | After flight lands or diverts |
| Label | `[Flight Debrief]` |
**Comment Format:**
```
[Flight Debrief] {YYYY-MM-DD}
**Status**: {landed | aborted}
**Duration**: {start} - {end}
**Legs Completed**: {X of Y}
## Outcome Assessment
{What the flight accomplished}
## What Went Well
{Effective patterns}
## What Could Be Improved
{Recommendations}
## Deviations
| Deviation | Reason | Standardize? |
|-----------|--------|--------------|
| {what} | {why} | {yes/no} |
## Key Learnings
{Insights for future flights}
## Recommendations
1. {Most impactful recommendation}
2. {Second recommendation}
3. {Third recommendation}
## Action Items
- [ ] {action}
```
---
### Mission Debrief → Epic Comment
| Property | Value |
|----------|-------|
| Location | Comment on the Mission (Epic) |
| Created | After mission completes or aborts |
| Label | `[Mission Debrief]` |
**Comment Format:**
```
[Mission Debrief] {YYYY-MM-DD}
**Status**: {completed | aborted}
**Duration**: {start} - {end}
**Flights Completed**: {X of Y}
## Success Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| {criterion} | {met/not met} | {notes} |
## Flight Summary
| Flight | Status | Outcome |
|--------|--------|---------|
| {flight} | {status} | {outcome} |
## What Went Well
{Successes}
## What Could Be Improved
{Improvements}
## Lessons Learned
{Insights}
## Action Items
- [ ] {action}
```
---
## State Mapping
### Mission (Epic)
| Flight Control | Jira Status |
|----------------|-------------|
| planning | To Do |
| active | In Progress |
| completed | Done |
| aborted | Cancelled |
### Flight (Story)
| Flight Control | Jira Status |
|----------------|-------------|
| planning | To Do |
| ready | Ready |
| in-flight | In Progress |
| landed | In Review |
| completed | Done |
| aborted | Cancelled |
### Leg (Sub-task)
| Flight Control | Jira Status |
|----------------|-------------|
| planning | To Do |
| ready | Ready |
| in-flight | In Progress |
| landed | In Review |
| completed | Done |
| aborted | Cancelled |
---
## Conventions
- **Naming**: Use clear, action-oriented summaries
- **Linking**: Always link Stories to Epic, Sub-tasks to Story
- **Labels**: Apply `flight-control` label to all artifacts
- **Immutability**: Never modify Sub-tasks once In Progress; create new ones
- **Comments**: Use prefixes (`[Flight Log]`, `[Flight Briefing]`, etc.) for filtering
## Git Workflow
| Property | Value |
|----------|-------|
| Strategy | `branch` |
**Options:**
- **`branch`** (default) — Single-checkout workflow. The orchestrator creates a feature branch and all agents work in the project root. One flight at a time per working copy.
- **`worktree`** — Worktree isolation. The orchestrator creates a git worktree under `.worktrees/` for each flight. Agents work in the worktree path. Parallel flights are possible on a single repo clone.
When using the `worktree` strategy, add `.worktrees/` to `.gitignore`.
@@ -79,12 +79,31 @@ Deep dive into the specific implementation:
- What error handling is required?
- If this leg modifies database schemas: does it include migration creation AND execution? Both must happen in the same leg — a schema defined but never migrated is a gap.
5. **Identify dependent code** (for interface changes)
5. **State-machine reachability audit**
For every state, status, or lifecycle value this leg introduces or relies on, verify nothing in a lower layer makes the state unreachable.
- Enumerate the infrastructure layers that could foreclose the state: DB constraints (FK ON DELETE behaviors, NOT NULL, CHECK constraints, triggers), application caches and their invalidation rules, API/protocol version compatibility, fallback handlers that silently mask the state, and indexes that imply assumptions.
- Audit existing tests for assertions that contradict the new design. A test that pins behavior the new state machine breaks must be inverted, renamed, or deleted as part of this leg — not after. The rename pattern (e.g., `test_X_does_Y``test_X_does_not_do_Y` with the assertion inverted) is preferred over delete-and-readd because it documents the intent shift in git blame.
- When a design requires a row to persist past a referenced entity's deletion, explicitly inspect the schema's FK behaviors and any tests that pin them. A `ON DELETE CASCADE` on a referencing column will silently delete the row your design needs to keep.
6. **Cache freshness contract**
For every cache (in-memory dict, query result cache, computed derived state, frontend session storage) this leg reads from or populates, declare an explicit freshness contract.
- **Source of truth**: which underlying data does this cache reflect?
- **Rebuild trigger**: pick exactly one — per-call rebuild, TTL with value, invalidation event, or accepted permanent staleness until process restart.
- **Maximum staleness acceptable to the user**: be specific. "Until process restart" is rarely acceptable for user-facing state where users expect their edits to apply.
- **User-action invalidation map**: list every user action that mutates the source-of-truth, and confirm each one invalidates or refreshes the cache. If a user can edit X in one UI surface but the cached X never sees the edit elsewhere, that mismatch will surface as a bug — name it now.
Conflating "the cached object works fine" with "the cached object reflects current config" is a common error. State health and state freshness are different contracts.
7. **Identify dependent code** (for interface changes)
- Does this leg modify shared interfaces?
- What files consume these interfaces?
- What files consume these interfaces? Run `grep -rn '<changed_symbol>' tests/ src/`; if any test or non-leg-scope source imports or calls it, signature changes break any "prior tests pass UNMODIFIED" acceptance criterion.
- Should updating consumers be part of this leg?
6. **Identify platform considerations**
8. **Identify platform considerations**
- Does this leg touch OS-specific features?
- What platform differences might affect implementation?
@@ -92,6 +111,34 @@ Deep dive into the specific implementation:
Create the leg artifact using the format defined in `.flightops/ARTIFACTS.md`.
### Phase 3b: Citation Verification
Before marking the leg `ready`, mechanically validate every code-location citation in the draft artifact against current code. Citations drift between when a flight is designed and when its legs are designed (intervening legs commit changes; source artifacts age). Catching drift here prevents the implementing agent from chasing a stale `file:line` into the wrong code.
1. **Extract citations**
- Scan the leg artifact for code-location references matching `path/to/file.ext:line` or `path/to/file.ext:line-line` patterns
- Also collect symbol-form citations: `path/to/file.ext:symbol_name`
- Skip references to non-source artifacts (`mission.md:42`, `flight.md:100`) — those are out of scope
2. **Verify each citation**
- Read the cited location in current code
- Compare against the snippet, symbol, or surrounding description provided in the leg
- Classify each:
- **`OK`** — content at the cited location matches the description
- **`drifted`** — content moved; the description is still accurate but the line number is wrong
- **`gone`** — described content no longer exists in the file (or the file itself is gone)
- **`unverifiable`** — citation has no snippet/symbol and the description is too vague to confirm
3. **Repair drift inline**
- For `drifted`: locate the new line number via grep on the snippet/symbol and update the citation in the leg artifact
- For `gone`: do not silently retire — flag for human review (the gap may have been independently fixed, OR the leg may now be obsolete)
- For `unverifiable`: rewrite the citation using one of the durable forms (see "Citing Code Locations" guideline)
4. **Append a Citation Audit summary**
- At the bottom of the leg artifact, append a clearly-titled section (something like `## Citation Audit` if the project's leg conventions don't dictate otherwise) summarizing the audit
- If all citations verified clean: one sentence — `N citations verified against current code at leg design time.`
- If any drift was repaired or flagged: list each one with classification and resolution
## Guidelines
### Writing Effective Objectives
@@ -132,6 +179,21 @@ For accessibility work, include specific checks:
- Screen reader commands to test
- Automated tool commands (Lighthouse, axe-core)
### Citing Code Locations
When the leg artifact references specific code, prefer durable forms over bare line numbers. Line numbers drift; symbols and snippets do not.
| Form | Example | When to use |
|------|---------|-------------|
| `file:symbol` | `the_one/api.py:create_provider` | Most cases — symbol names survive line shifts |
| `file:line — "snippet"` | `the_one/api.py:805 — "raise ProviderConfigError"` | When a specific line matters; the snippet is a self-verifier |
| `file:CONSTANT_NAME` | `web/middleware.py:GATED_METHODS` | Module-level constants and assignments |
| `file:line` (bare) | `api.py:805` | **Avoid** — brittle, no way to verify drift |
The snippet form is especially valuable: it lets Phase 3b mechanically confirm the cited content didn't move, and it tells the implementing agent exactly what they're looking at without needing to chase the line number.
When in doubt, include both — `the_one/api.py:805 (in create_provider) — "raise ProviderConfigError if base_url is empty"` — symbol + line + snippet covers all three drift modes.
### Implementation Guidance
Be explicit, not implicit:
@@ -1,11 +1,11 @@
---
name: routine-maintenance
description: Codebase health assessment and maintenance recommendation. Use after a mission or ad-hoc to verify codebase is flight-ready or scaffold a maintenance mission.
description: Post-mission codebase health assessment. Run after `/mission-debrief` to verify the codebase is flight-ready or scaffold a maintenance mission. Per-flight findings instead roll into the next flight or accumulate into an end-of-mission maintenance flight — not into this skill.
---
# Routine Maintenance
Perform an exhaustive, aviation-style codebase inspection. Can be triggered after a mission completes or run ad-hoc at any time. Produces a findings report and optionally scaffolds a maintenance mission for significant issues.
Perform an exhaustive, aviation-style codebase inspection after a mission completes. Produces a findings report and optionally scaffolds a maintenance mission for significant issues.
## Prerequisites
@@ -31,12 +31,11 @@ Perform an exhaustive, aviation-style codebase inspection. Can be triggered afte
- Deferred findings are those documented in prior reports but not addressed by a maintenance mission
- This ensures recurring issues are tracked across cycles rather than re-discovered as "new"
5. **Load mission and debrief documentation (if available)**
- If a recent mission exists, read it for outcome, success criteria, and known issues
- If a mission debrief exists, read it for lessons learned and action items
- If flight debriefs exist, read them for per-flight technical debt and recommendations
5. **Load mission and debrief documentation**
- Read the most recently completed mission for outcome, success criteria, and known issues
- Read its mission debrief for lessons learned and action items
- Read its flight debriefs for per-flight technical debt and recommendations
- This provides known-debt context so the inspection can distinguish new issues from acknowledged ones
- If no mission context is available (ad-hoc run), proceed without known-debt context
6. **Identify project stack**
- Read `README.md`, `CLAUDE.md`, and package files (`package.json`, `Cargo.toml`, `go.mod`, etc.)
@@ -178,6 +177,7 @@ Each agent receives:
- Provide the "Inspect Codebase" prompt from the crew file's Prompts section
- Include: applicable category list, project stack info, known debt from debriefs, user's areas of concern, and **scope assignment from the delegation plan**
- For partitioned inspections: each Inspector agent receives its module/area scope and only the categories relevant to its assignment
- In addition to the crew prompt, the Flight Director directly instructs the Inspector to: scan recent flight debriefs for any test metrics observations (timing, failures, skipped tests, flakes) and look for trends across them — rising suite times, recurring flakes, growing skip lists, persistent failures. Surface concrete optimization recommendations as Category 2 findings (e.g. parallelization, mocking, fixture hoisting, slow-test extraction, runner config), each tied to evidence from the trend.
- The Inspector performs broad automated checks and returns structured findings per category
#### Security Reviewer
+1
View File
@@ -2,6 +2,7 @@
projects.md
daily-briefings/
.claude/settings.local.json
.mcp.json
# OS files
.DS_Store
+22 -1
View File
@@ -49,7 +49,7 @@ Eleven skills automate the planning, execution, debrief, and oversight workflow:
| `/mission` | Create outcome-driven missions through research and interview |
| `/flight` | Create technical flight specs from missions |
| `/leg` | Generate implementation guidance for LLM execution |
| `/agentic-workflow` | Drive multi-agent flight execution (design, implement, review, commit) |
| `/agentic-workflow` | Drive multi-agent flight execution (design per leg, batch implement, single review and commit) |
| `/flight-debrief` | Post-flight analysis for continuous improvement |
| `/mission-debrief` | Post-mission retrospective for outcomes assessment |
| `/routine-maintenance` | Post-mission codebase health assessment and maintenance recommendation |
@@ -90,6 +90,27 @@ The registry provides:
- **Flights**: `planning``ready``in-flight``landed``completed` (or `aborted`)
- **Legs**: `planning``ready``in-flight``landed``completed` (or `aborted`)
## SkillProject Boundary
Mission Control skills run in projects whose owners can customize `.flightops/ARTIFACTS.md` and `.flightops/agent-crews/*.md` freely. Skills must not couple to project-owned shape:
- **Do not read project-owned artifacts by section heading.** When a skill needs to extract information from a prior debrief, maintenance report, or other project-owned artifact, frame the instruction by intent — what the agent is looking for — and let the agent locate it within whatever structure the project uses. Reading by literal heading name (e.g. `## Action Items`, `## Test Suite Timing`) breaks silently the moment a project owner renames or removes that section.
- **Do not write into project-owned artifacts at named anchors.** When a skill inserts content into a project artifact, describe the destination semantically ("in the section the project uses for X") rather than by literal heading. If the skill is appending a new section, suggest a heading without prescribing it as a contract.
- **Do not rely on crew prompt files to carry skill-required instructions.** The Flight Director must issue per-spawn instructions directly from the SKILL.md, even when the crew file also contains an overlapping prompt. Crew files are project-modifiable scaffolding; SKILL.md is the protocol.
See `docs/artifacts-md-ambiguities.md` for the full review of how the current ARTIFACTS.md template muddles this boundary.
## Project Information Stays in Project Artifacts
**Never store project-specific information in Claude Code memories** — not in mission-control's memory directory, not in any project's memory directory. Project-specific issues, bugs, technical debt, design gaps, known issues, and lessons learned belong exclusively in the project's own Flight Control artifacts:
- **Flight logs** — runtime decisions, deviations, anomalies
- **Flight debriefs** — post-flight analysis, recommendations, action items
- **Mission known issues** — cross-flight concerns discovered during execution
- **Design decision sections** — in flight and mission artifacts
Mission-control is a neutral methodology tool. Its memory (if used at all) is reserved for methodology preferences, user collaboration preferences, and cross-cutting tooling notes — never for project-specific content.
## Public Repository
This is a public repository. Keep all committed content anonymized:
+4 -10
View File
@@ -32,7 +32,7 @@ Aviation succeeds through layered planning and clear handoffs. Pilots follow fli
## Agentic Workflow
**LLM orchestrators**: Run `/agentic-workflow` to drive multi-agent flight execution with Claude Code. The skill orchestrates the full leg cycle — design, implement, review, commit using three separate Claude instances.
**LLM orchestrators**: Run `/agentic-workflow` to drive multi-agent flight execution with Claude Code. The skill designs and implements each leg in turn, then runs a single code review and commit across the whole flight, using separate Claude instances for the Flight Director, Developer, and Reviewer roles.
## Getting Started
@@ -49,13 +49,13 @@ Aviation succeeds through layered planning and clear handoffs. Pilots follow fli
3. **Initialize your project** — Run `/init-project` and select your project. This creates `.flightops/` in your target project with artifact configuration, methodology reference, and crew definitions.
4. **Review agent crew files** — Check the files in `{project}/.flightops/agent-crews/`. These define the crew composition (roles, models, prompts) for each phase. Customize them to your needs.
4. **Review agent crew files** — Check the files in `{target-project}/.flightops/agent-crews/`. These define the crew composition (roles, models, prompts) for each phase. Customize them to your needs.
5. **Create a mission** — Run `/mission`. This interviews you about desired outcomes and creates a mission artifact in your target project.
6. **Design a flight** — Run `/flight` to break the mission into a technical specification with pre/in/post-flight checklists.
7. **Execute** — Run `/agentic-workflow` to drive multi-agent implementation. This orchestrates design, implement, review, and commit cycles across legs.
7. **Execute** — Run `/agentic-workflow` to drive multi-agent implementation. This designs and implements each leg in turn, then reviews and commits the whole flight in one pass at the end.
8. **Debrief** — Run `/flight-debrief` and `/mission-debrief` after completion to capture lessons learned.
@@ -106,13 +106,7 @@ Mission
└── Leg
```
How you store these artifacts depends on your project's needs. Flight Control supports multiple artifact systems:
- **Markdown files** — Version-controlled documentation in your repository
- **Issue trackers** — Jira, Linear, GitHub Issues with linked relationships
- **Hybrid** — Missions in markdown, flights/legs as tickets
Each project configures its artifact system during initialization. The methodology and Claude Code skills adapt to your choice.
By default, artifacts are stored as version-controlled markdown files in your project's repository. Each project's `.flightops/ARTIFACTS.md` describes where and how artifacts live — skills read this file to determine locations and formats. You can adapt it to other backends (Jira, Linear, GitHub Issues, hybrid setups) by editing this file directly; only the markdown-files template ships out of the box.
## Claude Code Skills
@@ -0,0 +1,91 @@
# ARTIFACTS.md Template — Ambiguities
A review of `.claude/skills/init-project/templates/ARTIFACTS-files.md` (the template copied into each project's `.flightops/ARTIFACTS.md`) against the implicit contract Mission Control skills rely on.
## Context
Mission Control skills make assumptions about what they can read, write, and depend on inside a project. ARTIFACTS.md is the document that's supposed to encode the contract between skills and project-side customization. In practice, the template muddles three different layers:
1. **Mission Control protocol invariants** — fixed across all projects; skills depend on these by name (state values, lifecycle rules, artifact taxonomy).
2. **Project conventions skills must read** — locations, file naming, status field encoding.
3. **Project presentation** — section structure, prose templates, headings; never read by skills.
The template doesn't distinguish between these layers, which causes two failure modes:
- Project owners cannot tell what they can safely customize and what is fixed.
- Skill authors are not steered away from depending on project-owned shape (the most recent example: a draft flight-debrief change that read prior debriefs by `## Test Suite Timing` heading — a heading that lives in the project-owned format and could be removed or renamed at any time).
This document lists the specific ambiguities that motivated the finding.
## Findings
### 1. State values are protocol but presented as project-customizable
The `## State Tracking` table lists `planning → ready → in-flight → landed → completed (or aborted)` as part of the project's local convention. Skills depend on these literal strings:
- `flight-debrief/SKILL.md`: "A flight must have status `landed` before debriefing"; "update the flight artifact's status from `landed` to `completed`"
- `routine-maintenance/SKILL.md`: scaffolds new flights with `Status: ready`
If a project owner renames `landed` to `arrived` in their ARTIFACTS.md, skills break silently. The template gives no signal that this list is fixed by the protocol.
### 2. State transition ownership is unspecified
The state diagrams show transitions but don't say who triggers each one:
- `planning → active` for missions: which actor performs this? No skill currently does it.
- `ready → in-flight` for legs: skill or human?
- `landed → completed`: `flight-debrief` does this conditionally on user confirmation.
A project owner reading the template cannot tell which transitions they own vs which the skills handle.
### 3. "Format" is overloaded
Skills routinely say "use the format defined in `.flightops/ARTIFACTS.md`." The template conflates three different things under that one word:
- **Locations** (`missions/{NN}-{slug}/mission.md`) — skills genuinely depend on these
- **Status field encoding** (e.g. `**Status**: planning`) — skills need to read this
- **Section structure** (`## Outcome`, `## Context`, etc.) — skills should NOT depend on this
There is no rule stated anywhere that skills read locations and status fields but never read by section heading. Without that rule, every new skill edit risks coupling itself to project-owned section structure.
### 4. Conventions section mixes protocol invariants with format choices
In `## Conventions` (lines 556562 of the template):
- "Never modify legs once `in-flight`" — protocol invariant; project owners cannot opt out
- "Flight logs are append-only during execution" — protocol invariant
- "Mission as briefing: mission.md serves as both" — project choice (a different project might have a separate briefing file)
A project owner cannot tell which lines are invariants vs which are policies they accepted at init time and could revise.
### 5. The taxonomy itself isn't declared fixed
The artifact types — Mission, Flight, Leg, Debrief, Log, Briefing, Maintenance Report — are protocol concepts. Skills speak their names. But the template is structured as if it's describing one project's choices ("This project stores Flight Control artifacts as markdown files"), which invites a reader to think they could rename "Flight" to "Sprint" or drop "Leg" entirely.
### 6. Status field placement is ambiguously specified
"States are tracked in the frontmatter or status field of each artifact." Skills need a deterministic read path. If a project mixes the two — frontmatter for missions but `**Status**:` lines for flights — every skill that reads status must handle both. The template offers a choice without forcing the project to commit to one.
### 7. Template placeholders read ambiguously
`{NN}`, `{slug}`, `{Title}` — are these substituted by skills at artifact-generation time, or by the project owner during init configuration? They are the former, but the file presents them as if they are placeholders awaiting human substitution. A naive project owner might "fill in" `{slug}` with a literal value.
### 8. The init-project / migrations contradiction
`init-project/SKILL.md` says ARTIFACTS.md is project-specific and "Never overwrite — it may contain customizations." But `init-project/migrations.md` describes Mission Control modifying ARTIFACTS.md to update state definitions. Mission Control reserves the right to edit a "project-owned" file when its own protocol changes. The template does not disclose this boundary.
### 9. No statement of the skill/project contract
The largest gap. Nowhere does the template say *what skills will and will not do with this file*. Adding a preamble that explicitly states the contract would resolve most of the issues above:
> Mission Control skills read this file for: artifact locations, file naming, and status field encoding. Skills do not read artifacts by section heading. The State Tracking values and lifecycle rules are fixed by Mission Control and should not be edited. Everything else is yours to customize.
## Proposed direction (not yet implemented)
A small restructure of the template that sorts every line into one of three buckets:
- **`[protocol: do not edit]`** — state values, lifecycle invariants, artifact taxonomy
- **`[project: skills read this]`** — locations, file naming, status field encoding
- **`[project: presentation only]`** — section structure, prose, headings
Plus a preamble that names the skill/project contract explicitly. This clarifies the boundary in the document where new project owners and new skill authors actually look.