- Add notifications table with Alembic migration
- Notification model with user-scoped indexing and partial index on unread
- NotificationService singleton with create/list/count/mark-read/dismiss
- FastAPI router: GET /notifications, GET /unread, PATCH /{id}/read,
POST /mark-all-read, DELETE /{id}
- Mute categories filtering from UserConfig
- 13 unit tests for NotificationService
- 10 integration tests for API endpoints
- Updated test_models.py with new table registration
Quality gates: pytest 23 new passed, ruff clean
The previous sorting fix exposed a deeper bug: ResolvedMount always
mounted its staging directory as a single bind mount. When a config
profile mount targeted /workspace/x/y and contained a single file
z.json, the staging directory (containing only z.json) replaced the
ENTIRE /workspace/x/y directory, hiding all sibling files from git
repo mounts.
- Change apply_resolved_profile to mount each file individually:
- source: staging_dir/relative_path
- target: expanded_target/relative_path
- Sibling files from other mounts are preserved.
- Empty mounts produce no volume entries.
- Keep volume sorting (parent paths before child paths) which is
still necessary for directory mounts and ensures parent dirs exist
before file mounts inside them.
- Add 4 unit tests for file-level mount behavior.
Quality gates: pytest (218 passed, 6 pre-existing), tsc --noEmit (clean)
- Add instance_events and health_checks tables with Alembic migration
- InstanceEventBus: typed pub/sub singleton with wildcard support
- HealthMonitor: async background loop polling containers every 15s
- SSE endpoint GET /events/stream with auth and connection limits
- Lifecycle hooks in tool_instances.py (create/start/stop/restart/delete)
- Structured JSON logging with correlation IDs
- 15 new unit tests (EventBus, HealthMonitor, MonitoringModels)
Quality gates: pytest 15 new passed, ruff clean
- Add expand_container_path() helper that resolves ~/ and $HOME/ prefixes
- Add get_manifest_home_dir() to compute /home/{user.name} or /root from manifest
- Set ENV HOME=... and ENV USER=... in generated Dockerfile for runtime compatibility
- Pass home_dir through instance creation and startup pipeline
- Expand mount targets in apply_resolved_profile() for regular profile mounts
- Expand mapping targets in _resolve_git_mount_mappings() for git mounts
- Expand working_directory and volume targets in _modify_compose_file()
- Update _prepare_manifest_instance to return home_dir alongside image tag
- Fetch tool_type early in start_instance to determine home_dir before profile application
Quality gates: pytest 188 passed, frontend typecheck clean
Addresses: home-path-expansion
- Add mappings array support to git_mount entries
- Clone repository once per git_mount entry, mount multiple subdirectories
- Normalize legacy source_path+target_path to mappings on read
- Update _merge_git_mounts to dedup by (remote_url, branch) and concatenate mappings
- Add _normalize_git_mount, _clone_git_repo, _resolve_git_mount_mappings helpers
- Update GitMountItem Pydantic model with GitMountMapping and model_validator
- Update frontend GitMountEditor component with mappings UI
- Auto-convert legacy git mount entries to mappings format on load
- Add 15 backend unit tests for normalization, resolution, and glob expansion
- Update existing config profile resolver tests for new merge behavior
Quality gates: pytest 167 passed, frontend typecheck clean
Addresses: config-profile-multi-repo-mounts
Frontend:
- Remove 4 console.log statements from terminal.tsx that flooded the
browser console with WebSocket traffic (open, received X bytes, sending Y,
xterm focused)
Backend:
- Downgrade Dockerfile/entrypoint compilation logs from INFO to DEBUG in
_prepare_manifest_instance
- Remove hex-dump diagnostic logging from docker_build.py (was for
troubleshooting the backslash continuation bug, now fixed)
- Downgrade Dockerfile write log from INFO to DEBUG
The compile_dockerfile function used \\\\ in Python string literals,
which produces \ (two backslashes) in the Dockerfile output. Docker's
legacy builder requires a single backslash \ for line continuation.
This caused 'unknown instruction: curl' because Docker saw the first as the continuation and the second \ as a literal character before the
newline, breaking the RUN command parsing.
Fix: change all \\ to \ in Python string literals within
compile_dockerfile, producing the correct single-backslash continuation.
Verified with hex dump from container logs:
- Before: line ended with 5c5c (two backslashes)
- After: line ends with 5c (one backslash)
Docker's legacy builder treats \r as a literal character after a backslash
continuation, breaking RUN multi-line commands and producing
'unknown instruction: curl' errors.
Add defensive CRLF→LF normalisation for both Dockerfile and build context
files before writing. Also log hex representation of first 8 lines so we
can verify exactly what bytes Docker receives.
The container build fails with 'unknown instruction: curl' on line 6, which
suggests the Dockerfile continuation characters or line endings may be
malformed. Add defensive logging to diagnose:
- Force newline='\n' in all write_text calls in build_image for consistent
Unix line endings regardless of platform
- Log compiled Dockerfile and entrypoint content at INFO/DEBUG level
- Log Dockerfile byte count when written
This will let us see exactly what Docker is receiving in the next build attempt.
The terminal_sessions migration and drop_tool_configs migration both pointed
to add_tool_definition_manifests as their down_revision, creating two heads.
Update drop migration to depend on terminal_sessions instead, restoring a
single linear chain.
Three fixes for multi-session terminal bugs:
1. Race-condition double creation: The auto-create effect fired twice because
loadSessions returned 0 while an earlier createSession was still in flight.
Added hasAutoCreated guard ref to ensure only one auto-create happens.
2. Page reload spawns new sessions: After server restart, list_terminal_sessions
filtered out DB-only sessions (no in-memory counterpart), so the frontend
thought no sessions existed and auto-created new ones. Reverted the filter
so DB rows are always returned. The WebSocket handler now restores the
in-memory session from the DB row on demand when connecting.
3. No input after connection: The backend _read_loop would break on any send
error, causing asyncio.wait to cancel the _write_loop. Made _read_loop
retry up to 3 times before giving up, preventing transient send errors
from killing input handling.
Quality gates: pytest (15/15 passed), tsc clean
- Add ToolDefinitionManifest model with base image versioning
- Add manifest compiler: Dockerfile + Compose generation from JSON manifests
- Add permission fixer: post-start chown/chmod for mount policies
- Add tool definition CRUD API with live compile preview endpoint
- Integrate manifest-based startup flow in start_instance
- Add Alembic migration with data conversion for pi-agent
- Add 48 unit tests for manifest compiler, permission fixer, docker service
- Keep backward compatibility with legacy dockerfile_template/compose_template
Migration: applied successfully. Pi-agent converted to manifest.
Quality gates: pytest (146 passed, 4 pre-existing unrelated failures)
- Add TerminalSessionModel DB table with instance_id FK, name, status,
created_at, last_activity_at, closed_at columns
- Add Alembic migration for terminal_sessions table
- Refactor TerminalManager to use composite key (instance_id, session_id)
supporting up to 5 concurrent sessions per instance
- Add create_session, get_session, get_sessions_for_instance, close_session
- Preserve get_or_create_session for backward compatibility (default session)
- Fix attach_websocket to only close sockets within same session
- Add name (auto-generated 'Session N') and status tracking to TerminalSession
- Add 7 unit tests for multi-session logic
Quality gates: pytest (7 new passed, 174 total passed, 51 pre-existing failures)
- get_container_id() and get_container_name() now lowercase the
instance name before passing to docker ps --filter, because
Docker container names are lowercase internally and the filter
is case-sensitive. This caused container_id to never be captured
when instance.name contained uppercase chars (e.g. 'Headquarter'),
breaking terminal WebSocket connections.
- Also guard proc.stdout being None in start_cloudflared_tunnel().
- Add unit tests for get_container_id and get_container_name.
Quality gates: pytest (14 passed), python clean
- Add stdin_open: true and tty: true to dockerfile-based compose generation.
Without these, bash (PID 1) exits immediately, causing a container restart
loop that makes the instance invisible to docker ps and triggers 4004.
- Treat WebSocket close codes 4001/4003/4004 as permanent errors in the
frontend. Stop retrying and show the server reason to the user.
- Prevent visibilitychange handler from resetting retry attempts after a
permanent error has occurred.
- Use docker ps -a in get_container_id/get_container_name to find
stopped/exited containers for diagnostics.
Quality gates: tsc --noEmit (pass), pytest (98 passed, 4 pre-existing failures)
- Change git mount schema from repo_id to remote_url
- Remove database lookups for git mount resolution
- Clone directly from URL at instance startup
- Simplify frontend UI to text input for Git URL
- Fix route ordering in git_repositories.py to prevent 422 errors
- Update all tests to use remote_url field
Breaking change: Git mounts now use remote_url instead of repo_id
- Add startup_command field to ToolType model and API
- Execute startup command before interactive shell in terminal sessions
- Add tmux and ranger to OpenCode container spec
- Update Tool Workshop UI with startup_command input for terminal types
- Add backend tests for startup_command CRUD operations
- Sync specs: tool-terminal, tool-types-definition, opencode-web-server
- New spec: tool-terminal-startup-command
Quality gates: Frontend typecheck/lint passed. Backend tests blocked by environment (Python/Docker not available).
OpenSpec: terminal-startup-and-container-tools
Instead of sending stty commands through the user's terminal session
(which causes 'inappropriate ioctl' errors), send SIGWINCH signal to
the docker exec process on the host. Docker exec should forward this
to the container process, causing the shell to re-read its terminal size.
This avoids:
- Visible stty commands in the terminal
- ioctl errors from stty
- Interference with user's shell session
Reverted docker exec back to -it (required for interactive bash).
Instead, sends stty command with \r to hide it from the terminal display:
- \r moves cursor to start of line (overwrites prompt)
- stty command executes silently (no output on success)
- \r moves cursor back to start, hiding echoed command
This sends stty on EVERY resize so the container shell always matches
frontend dimensions.
Removes -t flag from docker exec so it uses our PTY slave directly instead
of creating its own PTY inside the container. This allows TIOCSWINSZ on the
host PTY master to propagate naturally to the container shell via SIGWINCH.
Also removes all stty command injection logic since resize now works natively.
Backend:
- Remove _stty_sent guard to send stty on EVERY resize
- Use stty -echo to hide command, then delete the command line with ANSI escapes
- Change log level from info to debug
Frontend:
- Add window resize listener as fallback to ResizeObserver
- 250ms debounce to avoid excessive refits
- Proper cleanup on unmount
Reverted terminal.tsx, terminal_session.py, and terminal.py to clean state
from before the resize debugging saga. Removed:
- Debug console.log statements
- Explicit term.resize() calls that broke xterm.js
- position: relative CSS overrides on .xterm
- stty -echo wrapper and asyncio.sleep delay
- Extra requestAnimationFrame refresh calls
Kept:
- Mobile terminal features (special keys, modifiers, font size)
- ResizeObserver for container resize detection
- Basic fit() and WebSocket resize messaging
- Add ConfigProfile and ConfigProfileInclude data models with migrations
- Implement profile resolver service with ordered includes and merge rules
- Add profile CRUD API with validation, compatibility, and cycle detection
- Add instance API plumbing for profile selection on create/start/restart
- Add resolved profile preview and default resolution APIs
- Add frontend config profile API client and management UI
- Add launch/restart profile selection UI
- Add backend integration and unit tests (31 passing)
OpenSpec: add-config-profiles
Quality gates: ruff, TypeScript compile, 31 tests passing
The _stty_sent guard prevented the container shell from updating its
terminal size after the first resize. This caused visual mismatches
where xterm.js displayed at the new size but the shell still wrapped
output at the old size.
Remove the guard so stty is sent on every resize event.
- Frontend: Add ResizeObserver with dimension tracking for accurate resize detection
- Frontend: Fix cleanup function to properly disconnect ResizeObserver
- Frontend: Use CSS grid for terminal wrapper layout
- Backend: Add duplicate dimension check to avoid unnecessary resizes
- Backend: Ensure stty command is sent correctly to container shell
The ResizeObserver detected size changes caused by the stty command
output appearing in the terminal, creating an infinite resize loop:
1. Resize detected -> fit() -> send resize to backend
2. Backend sends stty command through PTY
3. stty text appears in terminal output
4. ResizeObserver detects content height change
5. fit() calculates new rows -> send resize
6. Loop continues forever
Reverted to:
- Window resize event instead of ResizeObserver
- stty command only sent once on first resize
This means the container shell stays at the initial size and won't
dynamically resize when the browser window changes, but prevents
the infinite loop.
Previously the stty command was only sent on the first resize. Now it
is sent every time the terminal dimensions change, so resizing the
browser window or rotating the device properly updates the container
shell size. Added a check to skip when dimensions haven't changed.
Docker exec doesn't forward PTY resize to the container process,
so the container bash stays at 80x24 regardless of frontend resize.
Work around this by sending a stty command through the terminal
on first resize to set the correct dimensions inside the container.
Docker exec doesn't forward PTY resize to the container process,
so the container bash stays at 80x24 regardless of frontend resize.
Work around this by sending a stty command through the terminal
on first resize to set the correct dimensions inside the container.
Sending SIGWINCH to the docker exec process was crashing/killing it,
which closed the PTY and caused WebSocket 1006 abnormal closure loops.
Reverting to the original TIOCSWINSZ-only approach.
When resizing the PTY, docker exec needs to be notified so it can
re-read the terminal size and propagate it to the container's PTY.
Without this, the container shell stays at 80x24 regardless of what
the frontend sends.
TerminalManager was trying to create an asyncio task at module import time,
but no event loop exists yet during import. This caused RuntimeError on startup.
Changes:
- _start_idle_check() now checks if event loop is running before creating task
- If no loop exists, silently skips (will be started lazily)
- Added lazy start call in get_or_create_session() when websocket connects
- Terminal sessions now persist across WebSocket disconnections
- Added circular output buffer (10KB) for replay on reconnect
- Added idle timeout cleanup (30 minutes)
- Added reset functionality via WebSocket message and HTTP endpoint
- Concurrent connections close old WebSocket when new one connects
- Frontend: Added reset button with confirmation dialog
- Frontend: Handle resetting status and reconnection
Refs: persistent-terminal-sessions
- Add clone_mode and branch fields to tool_instances
- Add ssh_key_id to git_repositories for per-repo SSH key assignment
- Implement host-side git cloning with branch selection (default: main)
- Mount SSH keys into containers for git operations in clone mode
- Add dirty state check on clone-mode instance deletion with confirmation
- Update SessionsPage with mount/clone selector, branch input, SSH key display
- Add SSH key selector to repository creation form
- Add dirty delete confirmation modal with changed files list
- Update API schemas and endpoints for new fields
- Sync delta specs to main specs (git-repo, tool-instances, repo-clone-mode)
- Archive completed OpenSpec change: repo-clone-mode-with-ssh
- Document git requirement for custom tool types
Quality gates: Frontend typecheck and build passed
OpenSpec: repo-clone-mode-with-ssh archived with all tasks complete
- Add new status badges: starting, probing, unhealthy
- Show tunnel error only when tunnel_status is unreachable
- Show app error badge with status code for error_response
- Add collapsible probe output section for diagnostics
- Update health polling to check all active instances
- Only show Recreate Tunnel button for unreachable tunnels