mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-27 11:29:34 +01:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5078f5251d | ||
|
|
196af8fba7 | ||
|
|
a9b22b86a4 | ||
|
|
28cace5858 | ||
|
|
0a594b61bd | ||
|
|
89d787a949 | ||
|
|
bd9797b68c | ||
|
|
0e6cd94312 | ||
|
|
24a6f1cac8 | ||
|
|
8e679a280b | ||
|
|
c642689bbd | ||
|
|
28a6247c27 | ||
|
|
0ceb455c4b | ||
|
|
e51117dfa9 | ||
|
|
13d41cf7c7 | ||
|
|
2c7557d002 | ||
|
|
53b473708f | ||
|
|
2cba393ae5 | ||
|
|
64b8ea30b2 | ||
|
|
5743af3339 | ||
|
|
2011bd8d89 | ||
|
|
b76724690d | ||
|
|
f277f9664c | ||
|
|
cd49171bbc |
No files matched your search
@@ -1,5 +1,51 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 0.5.10
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- fix: allow bracket characters in model validation regex so models like opus[1m] (1M context window) are accepted instead of silently dropped. Quote the model flag value in tmux spawn commands to prevent bash glob expansion of bracket patterns.
|
||||||
|
|
||||||
|
docs: update macOS launchd instructions to use `launchctl bootstrap` instead of deprecated `load`. Clean up README install and service sections.
|
||||||
|
|
||||||
|
## 0.5.9
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Mobile keyboard accessory bar: add configurable "Extended Keyboard Bar" setting (Settings > Display > Input) that toggles between simple mode (up/down arrows, /init, /clear, /compact, paste, dismiss) and extended mode (adds left/right arrows, Tab, Shift+Tab, Ctrl+O, Alt+Enter, Esc). Default is simple mode. Setting is device-specific (not synced to server).
|
||||||
|
|
||||||
|
Restyle dismiss button: muted steel-blue tone, fills remaining bar space via flex, larger tap target. Arrow buttons now blue.
|
||||||
|
|
||||||
|
Fix paste overlay visibility on mobile: dialog repositioned to top of screen (15vh from top) so the virtual keyboard doesn't cover it. Textarea enlarged for better usability.
|
||||||
|
|
||||||
|
(Also includes all v0.5.8 changes: case reorder/delete, XSS sanitization, auto-attach PTY on restart, mobile keyboard buttons, macOS installer fixes, terminal flicker fix, state store collision fix.)
|
||||||
|
|
||||||
|
## 0.5.8
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Case management: add Manage tab with reorder (up/down arrows) and delete for cases; linked cases are unlinked (folder preserved), CASES_DIR cases are permanently deleted. New endpoints: DELETE /api/cases/:name, PUT /api/cases/order. SSE events: case:deleted, case:order-changed.
|
||||||
|
|
||||||
|
Security: sanitize case names from filesystem with /^[a-zA-Z0-9_-]+$/ regex before returning from GET /api/cases to prevent XSS via maliciously-named directories reaching frontend inline onclick handlers.
|
||||||
|
|
||||||
|
Auto-attach PTY: server now calls startInteractive() for recovered tmux sessions during startup so all sessions resume capturing output immediately after deploy, instead of waiting for client selection. Frontend auto-attach condition relaxed from (pid===null && status==='idle') to (pid===null && !\_ended).
|
||||||
|
|
||||||
|
Mobile keyboard accessory: add Shift+Tab, Tab, Esc, Alt+Enter, Left/Right arrow, and Ctrl+O buttons.
|
||||||
|
|
||||||
|
Terminal: fix flicker regression by moving viewport clear inside dimension guard.
|
||||||
|
|
||||||
|
State store: fix temp file collisions on concurrent writes.
|
||||||
|
|
||||||
|
macOS: fix installer failures when piped via curl | bash, add HTML cache support, launchd service template, and trust dialog handling.
|
||||||
|
|
||||||
|
Housekeeping: remove accidentally committed dist/state-store.js build artifact.
|
||||||
|
|
||||||
|
## 0.5.7
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- feat: support "Default (CLI default)" option for model selection. Adds a new empty-value option to the model dropdown that defers to the CLI's own default model instead of forcing a specific model. Ensures empty defaultModel values are treated as undefined when passed to session creation and Ralph loop start, preventing empty strings from being sent as model flags.
|
||||||
|
|
||||||
## 0.5.6
|
## 0.5.6
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ When user says "COM":
|
|||||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
||||||
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||||
|
|
||||||
**Version**: 0.5.6 (must match `package.json`)
|
**Version**: 0.5.10 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
@@ -30,24 +30,6 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | b
|
|||||||
|
|
||||||
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it.
|
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it.
|
||||||
|
|
||||||
**Install from a fork or specific branch:**
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/<user>/Codeman/<branch>/install.sh | \
|
|
||||||
CODEMAN_REPO_URL=https://github.com/<user>/Codeman.git \
|
|
||||||
CODEMAN_BRANCH=<branch> bash
|
|
||||||
```
|
|
||||||
|
|
||||||
The installer supports these environment variables:
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `CODEMAN_REPO_URL` | upstream Codeman | Custom git repository URL |
|
|
||||||
| `CODEMAN_BRANCH` | `master` | Git branch to install |
|
|
||||||
| `CODEMAN_INSTALL_DIR` | `~/.codeman/app` | Custom install directory |
|
|
||||||
| `CODEMAN_SKIP_SYSTEMD` | `0` | Skip systemd service setup prompt |
|
|
||||||
| `CODEMAN_NODE_VERSION` | `22` | Node.js major version to install |
|
|
||||||
| `CODEMAN_NONINTERACTIVE` | `0` | Skip all prompts (for CI/automation) |
|
|
||||||
|
|
||||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai) (or both). After install:
|
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai) (or both). After install:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -60,12 +42,53 @@ codeman web
|
|||||||
|
|
||||||
**Linux (systemd):**
|
**Linux (systemd):**
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/.config/systemd/user && printf '[Unit]\nDescription=Codeman Web Server\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=%s %s/dist/index.js web\nRestart=always\nRestartSec=10\n\n[Install]\nWantedBy=default.target\n' "$(which node)" "$HOME/.codeman/app" > ~/.config/systemd/user/codeman-web.service && systemctl --user daemon-reload && systemctl --user enable --now codeman-web && loginctl enable-linger $USER
|
mkdir -p ~/.config/systemd/user
|
||||||
|
cat > ~/.config/systemd/user/codeman-web.service << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Codeman Web Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=$(which node) $HOME/.codeman/app/dist/index.js web
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
EOF
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now codeman-web
|
||||||
|
loginctl enable-linger $USER
|
||||||
```
|
```
|
||||||
|
|
||||||
**macOS (launchd):**
|
**macOS (launchd):**
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/Library/LaunchAgents && printf '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>com.codeman.web</string><key>ProgramArguments</key><array><string>%s</string><string>%s/dist/index.js</string><string>web</string></array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>StandardOutPath</key><string>/tmp/codeman.log</string><key>StandardErrorPath</key><string>/tmp/codeman.log</string></dict></plist>\n' "$(which node)" "$HOME/.codeman/app" > ~/Library/LaunchAgents/com.codeman.web.plist && launchctl load ~/Library/LaunchAgents/com.codeman.web.plist
|
mkdir -p ~/Library/LaunchAgents
|
||||||
|
cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.codeman.web</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>$(which node)</string>
|
||||||
|
<string>$HOME/.codeman/app/dist/index.js</string>
|
||||||
|
<string>web</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key><true/>
|
||||||
|
<key>KeepAlive</key><true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/tmp/codeman.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/tmp/codeman.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
+147
-22
@@ -7,7 +7,7 @@
|
|||||||
# Environment variables:
|
# Environment variables:
|
||||||
# CODEMAN_NONINTERACTIVE=1 - Skip all prompts (for CI/automation)
|
# CODEMAN_NONINTERACTIVE=1 - Skip all prompts (for CI/automation)
|
||||||
# CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app)
|
# CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app)
|
||||||
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd service setup prompt
|
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd/launchd service setup prompt
|
||||||
# CODEMAN_NODE_VERSION - Node.js major version to install (default: 22)
|
# CODEMAN_NODE_VERSION - Node.js major version to install (default: 22)
|
||||||
# CODEMAN_REPO_URL - Custom git repository URL (default: upstream Codeman)
|
# CODEMAN_REPO_URL - Custom git repository URL (default: upstream Codeman)
|
||||||
# CODEMAN_BRANCH - Git branch to install (default: master)
|
# CODEMAN_BRANCH - Git branch to install (default: master)
|
||||||
@@ -353,8 +353,15 @@ ensure_sudo() {
|
|||||||
die "sudo is required but not installed. Please install packages manually or run as root."
|
die "sudo is required but not installed. Please install packages manually or run as root."
|
||||||
fi
|
fi
|
||||||
# Validate sudo access
|
# Validate sudo access
|
||||||
if ! sudo -v 2>/dev/null; then
|
# When piped (curl | bash), stdin is the pipe — redirect from /dev/tty so sudo can prompt
|
||||||
die "Failed to obtain sudo privileges."
|
if [[ -e /dev/tty ]]; then
|
||||||
|
if ! sudo -v 2>/dev/null < /dev/tty; then
|
||||||
|
die "Failed to obtain sudo privileges."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if ! sudo -v 2>/dev/null; then
|
||||||
|
die "Failed to obtain sudo privileges. Try running the script directly instead of piping."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +379,12 @@ ensure_homebrew() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
info "Installing Homebrew first..."
|
info "Installing Homebrew first..."
|
||||||
/bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
# When piped (curl | bash), stdin is the pipe — Homebrew needs TTY for sudo password prompt
|
||||||
|
if [[ -e /dev/tty ]]; then
|
||||||
|
/bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" < /dev/tty
|
||||||
|
else
|
||||||
|
NONINTERACTIVE=1 /bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||||
|
fi
|
||||||
|
|
||||||
# Add Homebrew to PATH for Apple Silicon
|
# Add Homebrew to PATH for Apple Silicon
|
||||||
if [[ -f /opt/homebrew/bin/brew ]]; then
|
if [[ -f /opt/homebrew/bin/brew ]]; then
|
||||||
@@ -787,9 +799,84 @@ setup_sc_alias() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Systemd Service Setup (Linux only)
|
# Service Setup (Linux systemd / macOS launchd)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
setup_launchd_service() {
|
||||||
|
local plist_label="com.codeman.web"
|
||||||
|
local agent_dir="$HOME/Library/LaunchAgents"
|
||||||
|
local agent_plist="$agent_dir/$plist_label.plist"
|
||||||
|
local daemon_plist="/Library/LaunchDaemons/$plist_label.plist"
|
||||||
|
|
||||||
|
info "Setting up macOS LaunchAgent..."
|
||||||
|
|
||||||
|
# Remove any existing LaunchDaemon (system-level) to prevent duplicates.
|
||||||
|
# We standardize on LaunchAgent (user-level) — it doesn't require sudo,
|
||||||
|
# inherits the user's environment, and is the correct choice for user apps.
|
||||||
|
if [[ -f "$daemon_plist" ]]; then
|
||||||
|
warn "Found system-level LaunchDaemon at $daemon_plist — removing to prevent duplicate"
|
||||||
|
sudo launchctl unload "$daemon_plist" 2>/dev/null || true
|
||||||
|
sudo rm -f "$daemon_plist"
|
||||||
|
success "Removed duplicate LaunchDaemon"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Unload existing agent before overwriting
|
||||||
|
if [[ -f "$agent_plist" ]]; then
|
||||||
|
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$agent_dir"
|
||||||
|
|
||||||
|
# Build PATH: ensure /opt/homebrew/bin (Apple Silicon) and ~/.local/bin are included
|
||||||
|
local svc_path="/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||||
|
|
||||||
|
# Find node binary path
|
||||||
|
local node_path
|
||||||
|
node_path=$(command -v node)
|
||||||
|
|
||||||
|
cat > "$agent_plist" << EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>$plist_label</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>$node_path</string>
|
||||||
|
<string>$INSTALL_DIR/dist/index.js</string>
|
||||||
|
<string>web</string>
|
||||||
|
</array>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>$svc_path</string>
|
||||||
|
<key>HOME</key>
|
||||||
|
<string>$HOME</string>
|
||||||
|
<key>LANG</key>
|
||||||
|
<string>en_US.UTF-8</string>
|
||||||
|
</dict>
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>$HOME</string>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>ThrottleInterval</key>
|
||||||
|
<integer>10</integer>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/tmp/codeman.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/tmp/codeman.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
launchctl load "$agent_plist" 2>/dev/null || true
|
||||||
|
|
||||||
|
success "LaunchAgent installed and started"
|
||||||
|
}
|
||||||
|
|
||||||
setup_systemd_service() {
|
setup_systemd_service() {
|
||||||
local service_dir="$HOME/.config/systemd/user"
|
local service_dir="$HOME/.config/systemd/user"
|
||||||
local service_file="$service_dir/codeman-web.service"
|
local service_file="$service_dir/codeman-web.service"
|
||||||
@@ -1139,17 +1226,25 @@ main() {
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
local launch_choice=""
|
local launch_choice=""
|
||||||
local has_systemd=false
|
local has_service=false
|
||||||
|
local service_type=""
|
||||||
|
|
||||||
if [[ "$os" == "linux" ]] && [[ "$SKIP_SYSTEMD" != "1" ]] && command -v systemctl &>/dev/null; then
|
if [[ "$os" == "linux" ]] && [[ "$SKIP_SYSTEMD" != "1" ]] && command -v systemctl &>/dev/null; then
|
||||||
has_systemd=true
|
has_service=true
|
||||||
|
service_type="systemd"
|
||||||
|
elif [[ "$os" == "macos" ]] && [[ "$SKIP_SYSTEMD" != "1" ]]; then
|
||||||
|
has_service=true
|
||||||
|
service_type="launchd"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$has_systemd" == "true" ]]; then
|
if [[ "$has_service" == "true" ]]; then
|
||||||
|
local service_label="systemd service"
|
||||||
|
[[ "$service_type" == "launchd" ]] && service_label="LaunchAgent"
|
||||||
|
|
||||||
echo -e " ${BOLD}How would you like to run Codeman?${NC}"
|
echo -e " ${BOLD}How would you like to run Codeman?${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${CYAN}1)${NC} Run now in this terminal"
|
echo -e " ${CYAN}1)${NC} Run now in this terminal"
|
||||||
echo -e " ${CYAN}2)${NC} Install as systemd service (auto-start on boot)"
|
echo -e " ${CYAN}2)${NC} Install as $service_label (auto-start on boot)"
|
||||||
echo -e " ${CYAN}3)${NC} Don't start — I'll run it later"
|
echo -e " ${CYAN}3)${NC} Don't start — I'll run it later"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
@@ -1166,7 +1261,7 @@ main() {
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# macOS or no systemd — only offer run now or skip
|
# No service manager available — only offer run now or skip
|
||||||
echo -e " ${BOLD}Would you like to start Codeman now?${NC}"
|
echo -e " ${BOLD}Would you like to start Codeman now?${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${CYAN}1)${NC} Run now in this terminal"
|
echo -e " ${CYAN}1)${NC} Run now in this terminal"
|
||||||
@@ -1192,12 +1287,16 @@ main() {
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Handle systemd setup
|
# Handle service setup
|
||||||
if [[ "$launch_choice" == "2" ]]; then
|
if [[ "$launch_choice" == "2" ]]; then
|
||||||
setup_systemd_service
|
if [[ "$service_type" == "launchd" ]]; then
|
||||||
|
setup_launchd_service
|
||||||
|
else
|
||||||
|
setup_systemd_service
|
||||||
|
fi
|
||||||
|
|
||||||
# Offer tunnel service if cloudflared is available
|
# Offer tunnel service if cloudflared is available (Linux only — systemd tunnel service)
|
||||||
if check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
|
if [[ "$service_type" == "systemd" ]] && check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then
|
if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then
|
||||||
setup_tunnel_service
|
setup_tunnel_service
|
||||||
@@ -1212,10 +1311,16 @@ main() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo -e " ${BOLD}Manage the service:${NC}"
|
echo -e " ${BOLD}Manage the service:${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${CYAN}systemctl --user stop codeman-web${NC} # Stop"
|
if [[ "$service_type" == "launchd" ]]; then
|
||||||
echo -e " ${CYAN}systemctl --user restart codeman-web${NC} # Restart"
|
echo -e " ${CYAN}launchctl unload ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Stop"
|
||||||
echo -e " ${CYAN}systemctl --user status codeman-web${NC} # Check status"
|
echo -e " ${CYAN}launchctl load ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Start"
|
||||||
echo -e " ${CYAN}journalctl --user -u codeman-web -f${NC} # View logs"
|
echo -e " ${CYAN}tail -f /tmp/codeman.log${NC} # View logs"
|
||||||
|
else
|
||||||
|
echo -e " ${CYAN}systemctl --user stop codeman-web${NC} # Stop"
|
||||||
|
echo -e " ${CYAN}systemctl --user restart codeman-web${NC} # Restart"
|
||||||
|
echo -e " ${CYAN}systemctl --user status codeman-web${NC} # Check status"
|
||||||
|
echo -e " ${CYAN}journalctl --user -u codeman-web -f${NC} # View logs"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1289,11 +1394,17 @@ update() {
|
|||||||
success "Updated to $(node -e "console.log(require('./package.json').version)")"
|
success "Updated to $(node -e "console.log(require('./package.json').version)")"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Auto-restart systemd service if it's running, otherwise tell the user
|
# Auto-restart service if running, otherwise tell the user
|
||||||
if systemctl --user is-active codeman-web.service &>/dev/null; then
|
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||||
|
if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then
|
||||||
info "Restarting codeman-web service..."
|
info "Restarting codeman-web service..."
|
||||||
systemctl --user restart codeman-web.service
|
systemctl --user restart codeman-web.service
|
||||||
success "codeman-web service restarted"
|
success "codeman-web service restarted"
|
||||||
|
elif [[ -f "$agent_plist" ]]; then
|
||||||
|
info "Restarting LaunchAgent..."
|
||||||
|
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||||
|
launchctl load "$agent_plist" 2>/dev/null || true
|
||||||
|
success "LaunchAgent restarted"
|
||||||
else
|
else
|
||||||
echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
|
echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
|
||||||
echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}"
|
echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}"
|
||||||
@@ -1306,9 +1417,9 @@ uninstall() {
|
|||||||
info "Uninstalling Codeman..."
|
info "Uninstalling Codeman..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Stop and remove systemd services
|
# Stop and remove systemd services (Linux)
|
||||||
for svc in codeman-web codeman-tunnel; do
|
for svc in codeman-web codeman-tunnel; do
|
||||||
if systemctl --user is-active "${svc}.service" &>/dev/null; then
|
if systemctl --user is-active "${svc}.service" &>/dev/null 2>&1; then
|
||||||
info "Stopping ${svc} service..."
|
info "Stopping ${svc} service..."
|
||||||
systemctl --user stop "${svc}.service"
|
systemctl --user stop "${svc}.service"
|
||||||
fi
|
fi
|
||||||
@@ -1324,6 +1435,20 @@ uninstall() {
|
|||||||
done
|
done
|
||||||
systemctl --user daemon-reload 2>/dev/null || true
|
systemctl --user daemon-reload 2>/dev/null || true
|
||||||
|
|
||||||
|
# Stop and remove launchd services (macOS)
|
||||||
|
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||||
|
local daemon_plist="/Library/LaunchDaemons/com.codeman.web.plist"
|
||||||
|
if [[ -f "$agent_plist" ]]; then
|
||||||
|
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||||
|
rm -f "$agent_plist"
|
||||||
|
success "Removed LaunchAgent"
|
||||||
|
fi
|
||||||
|
if [[ -f "$daemon_plist" ]]; then
|
||||||
|
sudo launchctl unload "$daemon_plist" 2>/dev/null || true
|
||||||
|
sudo rm -f "$daemon_plist"
|
||||||
|
success "Removed LaunchDaemon"
|
||||||
|
fi
|
||||||
|
|
||||||
# Remove symlinks
|
# Remove symlinks
|
||||||
local symlink_dir="$HOME/.local/bin"
|
local symlink_dir="$HOME/.local/bin"
|
||||||
if [[ -L "$symlink_dir/codeman" ]]; then
|
if [[ -L "$symlink_dir/codeman" ]]; then
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "0.5.6",
|
"version": "0.5.10",
|
||||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ export class Session extends EventEmitter {
|
|||||||
private _lastPromptTime: number = 0;
|
private _lastPromptTime: number = 0;
|
||||||
private activityTimeout: NodeJS.Timeout | null = null;
|
private activityTimeout: NodeJS.Timeout | null = null;
|
||||||
private _awaitingIdleConfirmation: boolean = false; // Prevents timeout reset during idle detection
|
private _awaitingIdleConfirmation: boolean = false; // Prevents timeout reset during idle detection
|
||||||
|
private _trustDialogAccepted: boolean = false; // Prevents repeated trust dialog auto-accept
|
||||||
private _taskTracker: TaskTracker;
|
private _taskTracker: TaskTracker;
|
||||||
|
|
||||||
// Token tracking for auto-clear
|
// Token tracking for auto-clear
|
||||||
@@ -1118,6 +1119,16 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
this._handleTerminalOutput(data);
|
this._handleTerminalOutput(data);
|
||||||
|
|
||||||
|
// === Auto-accept workspace trust dialog ===
|
||||||
|
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
|
||||||
|
// Codeman sessions always use --dangerously-skip-permissions, so auto-accept.
|
||||||
|
if (!this._trustDialogAccepted && data.includes('trust this folder')) {
|
||||||
|
this._trustDialogAccepted = true;
|
||||||
|
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
|
||||||
|
// Send Enter to accept the default selection ("Yes, I trust this folder")
|
||||||
|
this.writeViaMux('\r');
|
||||||
|
}
|
||||||
|
|
||||||
// === Idle/working detection runs on every chunk (latency-sensitive) ===
|
// === Idle/working detection runs on every chunk (latency-sensitive) ===
|
||||||
// Detect if Claude is working or at prompt
|
// Detect if Claude is working or at prompt
|
||||||
// The prompt line contains "❯" when waiting for input
|
// The prompt line contains "❯" when waiting for input
|
||||||
|
|||||||
+2
-2
@@ -309,7 +309,7 @@ export class StateStore {
|
|||||||
|
|
||||||
this.ensureDir();
|
this.ensureDir();
|
||||||
|
|
||||||
const tempPath = this.filePath + '.tmp';
|
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
||||||
const backupPath = this.filePath + '.bak';
|
const backupPath = this.filePath + '.bak';
|
||||||
|
|
||||||
// Step 1: Serialize state (validates it's JSON-safe)
|
// Step 1: Serialize state (validates it's JSON-safe)
|
||||||
@@ -373,7 +373,7 @@ export class StateStore {
|
|||||||
|
|
||||||
this.ensureDir();
|
this.ensureDir();
|
||||||
|
|
||||||
const tempPath = this.filePath + '.tmp';
|
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
||||||
const backupPath = this.filePath + '.bak';
|
const backupPath = this.filePath + '.bak';
|
||||||
|
|
||||||
const json = this.serializeState();
|
const json = this.serializeState();
|
||||||
|
|||||||
+2
-2
@@ -185,8 +185,8 @@ function buildSpawnCommand(options: {
|
|||||||
}): string {
|
}): string {
|
||||||
if (options.mode === 'claude') {
|
if (options.mode === 'claude') {
|
||||||
// Validate model to prevent command injection
|
// Validate model to prevent command injection
|
||||||
const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined;
|
const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined;
|
||||||
const modelFlag = safeModel ? ` --model ${safeModel}` : '';
|
const modelFlag = safeModel ? ` --model "${safeModel}"` : '';
|
||||||
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
|
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
|
||||||
// Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
|
// Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
|
||||||
// fall back to a new session with --session-id so the pane doesn't die.
|
// fall back to a new session with --session-id so the pane doesn't die.
|
||||||
|
|||||||
@@ -523,6 +523,9 @@ class CodemanApp {
|
|||||||
SwipeHandler.init();
|
SwipeHandler.init();
|
||||||
VoiceInput.init();
|
VoiceInput.init();
|
||||||
KeyboardAccessoryBar.init();
|
KeyboardAccessoryBar.init();
|
||||||
|
// Apply keyboard bar mode from settings
|
||||||
|
const _kbSettings = this.loadAppSettingsFromStorage();
|
||||||
|
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
||||||
this.applyHeaderVisibilitySettings();
|
this.applyHeaderVisibilitySettings();
|
||||||
this.applyTabWrapSettings();
|
this.applyTabWrapSettings();
|
||||||
this.applyMonitorVisibility();
|
this.applyMonitorVisibility();
|
||||||
@@ -2105,8 +2108,9 @@ class CodemanApp {
|
|||||||
|
|
||||||
// Track working directory for path normalization in Project Insights
|
// Track working directory for path normalization in Project Insights
|
||||||
this.currentSessionWorkingDir = session?.workingDir || null;
|
this.currentSessionWorkingDir = session?.workingDir || null;
|
||||||
if (session && session.pid === null && session.status === 'idle') {
|
if (session && session.pid === null && !session._ended) {
|
||||||
// This is a restored session - attach to the existing screen/shell
|
// Session has no PTY attached — either restored after server restart
|
||||||
|
// or detached for some other reason. Re-attach regardless of status.
|
||||||
try {
|
try {
|
||||||
const endpoint = session.mode === 'shell'
|
const endpoint = session.mode === 'shell'
|
||||||
? `/api/sessions/${sessionId}/shell`
|
? `/api/sessions/${sessionId}/shell`
|
||||||
|
|||||||
@@ -291,6 +291,12 @@ const SSE_EVENTS = {
|
|||||||
ORCHESTRATOR_TASK_FAILED: 'orchestrator:taskFailed',
|
ORCHESTRATOR_TASK_FAILED: 'orchestrator:taskFailed',
|
||||||
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
|
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
|
||||||
ORCHESTRATOR_ERROR: 'orchestrator:error',
|
ORCHESTRATOR_ERROR: 'orchestrator:error',
|
||||||
|
|
||||||
|
// Cases
|
||||||
|
CASE_CREATED: 'case:created',
|
||||||
|
CASE_LINKED: 'case:linked',
|
||||||
|
CASE_DELETED: 'case:deleted',
|
||||||
|
CASE_ORDER_CHANGED: 'case:order-changed',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -892,6 +892,17 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-item settings-item-multiline" title="Show additional buttons (Tab, Shift+Tab, Ctrl+O, Esc, Alt+Enter, left/right arrows) in the mobile keyboard accessory bar.">
|
||||||
|
<div class="settings-item-text">
|
||||||
|
<span class="settings-item-label">Extended Keyboard Bar</span>
|
||||||
|
<span class="settings-item-desc">Extra keys: Tab, Esc, arrows, Ctrl+O</span>
|
||||||
|
</div>
|
||||||
|
<label class="switch switch-sm">
|
||||||
|
<input type="checkbox" id="appSettingsExtendedKeyboardBar">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Header Displays Section -->
|
<!-- Header Displays Section -->
|
||||||
<div class="settings-section-header">Header Displays</div>
|
<div class="settings-section-header">Header Displays</div>
|
||||||
<div class="settings-item" title="Show A-/A+ font size buttons in header">
|
<div class="settings-item" title="Show A-/A+ font size buttons in header">
|
||||||
@@ -1085,6 +1096,7 @@
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Default Model</label>
|
<label>Default Model</label>
|
||||||
<select id="appSettingsDefaultModel" class="form-select">
|
<select id="appSettingsDefaultModel" class="form-select">
|
||||||
|
<option value="">Default (CLI default)</option>
|
||||||
<option value="opus">Opus (Most capable)</option>
|
<option value="opus">Opus (Most capable)</option>
|
||||||
<option value="sonnet">Sonnet (Balanced)</option>
|
<option value="sonnet">Sonnet (Balanced)</option>
|
||||||
<option value="haiku">Haiku (Fast & cheap)</option>
|
<option value="haiku">Haiku (Fast & cheap)</option>
|
||||||
@@ -1359,6 +1371,7 @@
|
|||||||
<div class="modal-tabs">
|
<div class="modal-tabs">
|
||||||
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
|
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
|
||||||
<button class="modal-tab-btn" data-tab="case-link">Link Existing</button>
|
<button class="modal-tab-btn" data-tab="case-link">Link Existing</button>
|
||||||
|
<button class="modal-tab-btn" data-tab="case-manage">Manage</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<!-- Create New Tab -->
|
<!-- Create New Tab -->
|
||||||
@@ -1386,6 +1399,13 @@
|
|||||||
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
|
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Manage Tab -->
|
||||||
|
<div class="modal-tab-content hidden" id="case-manage">
|
||||||
|
<div class="case-manage-list" id="caseManageList">
|
||||||
|
<!-- Populated by JS -->
|
||||||
|
</div>
|
||||||
|
<span class="form-hint" style="margin-top: 8px; display: block;">Use arrows to reorder. Changes are saved automatically.</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn-toolbar" onclick="app.closeCreateCaseModal()">Cancel</button>
|
<button class="btn-toolbar" onclick="app.closeCreateCaseModal()">Cancel</button>
|
||||||
|
|||||||
@@ -33,16 +33,10 @@
|
|||||||
*/
|
*/
|
||||||
const KeyboardAccessoryBar = {
|
const KeyboardAccessoryBar = {
|
||||||
element: null,
|
element: null,
|
||||||
|
_mode: 'simple', // 'simple' or 'extended'
|
||||||
|
|
||||||
/** Create and inject the accessory bar */
|
/** HTML for simple mode: arrows, commands, paste, dismiss */
|
||||||
init() {
|
_simpleButtons: `
|
||||||
// Only on mobile
|
|
||||||
if (!MobileDetection.isTouchDevice()) return;
|
|
||||||
|
|
||||||
// Create accessory bar element
|
|
||||||
this.element = document.createElement('div');
|
|
||||||
this.element.className = 'keyboard-accessory-bar';
|
|
||||||
this.element.innerHTML = `
|
|
||||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
<path d="M5 15l7-7 7 7"/>
|
<path d="M5 15l7-7 7 7"/>
|
||||||
@@ -66,8 +60,59 @@ const KeyboardAccessoryBar = {
|
|||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||||
<path d="M19 9l-7 7-7-7"/>
|
<path d="M19 9l-7 7-7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
</button>`,
|
||||||
|
|
||||||
|
/** HTML for extended mode: all keys including arrows, Tab, Esc, etc. */
|
||||||
|
_extendedButtons: `
|
||||||
|
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M5 15l7-7 7 7"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
`;
|
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-down" title="Arrow down">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-left" title="Arrow left">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-right" title="Arrow right">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||||
|
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||||
|
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
|
||||||
|
<button class="accessory-btn" data-action="ctrl-o" title="Ctrl+O">⌃O</button>
|
||||||
|
<button class="accessory-btn" data-action="opt-enter" title="Option+Enter (newline)">⌥Enter</button>
|
||||||
|
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
||||||
|
<button class="accessory-btn" data-action="init" title="/init">/init</button>
|
||||||
|
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
|
||||||
|
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
|
||||||
|
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||||
|
<path d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>`,
|
||||||
|
|
||||||
|
/** Create and inject the accessory bar */
|
||||||
|
init() {
|
||||||
|
// Only on mobile
|
||||||
|
if (!MobileDetection.isTouchDevice()) return;
|
||||||
|
|
||||||
|
// Create accessory bar element
|
||||||
|
this.element = document.createElement('div');
|
||||||
|
this.element.className = 'keyboard-accessory-bar';
|
||||||
|
this.element.innerHTML = this._simpleButtons;
|
||||||
|
|
||||||
// Add click handlers — preventDefault stops event from reaching terminal
|
// Add click handlers — preventDefault stops event from reaching terminal
|
||||||
this.element.addEventListener('click', (e) => {
|
this.element.addEventListener('click', (e) => {
|
||||||
@@ -80,7 +125,8 @@ const KeyboardAccessoryBar = {
|
|||||||
this.handleAction(action, btn);
|
this.handleAction(action, btn);
|
||||||
|
|
||||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||||
if ((action === 'scroll-up' || action === 'scroll-down') ||
|
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc']);
|
||||||
|
if (refocusActions.has(action) ||
|
||||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||||
if (typeof app !== 'undefined' && app.terminal) {
|
if (typeof app !== 'undefined' && app.terminal) {
|
||||||
app.terminal.focus();
|
app.terminal.focus();
|
||||||
@@ -95,6 +141,14 @@ const KeyboardAccessoryBar = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Switch between 'simple' and 'extended' button layouts */
|
||||||
|
setMode(mode) {
|
||||||
|
if (mode === this._mode || !this.element) return;
|
||||||
|
this._mode = mode;
|
||||||
|
this.clearConfirm();
|
||||||
|
this.element.innerHTML = mode === 'extended' ? this._extendedButtons : this._simpleButtons;
|
||||||
|
},
|
||||||
|
|
||||||
_confirmTimer: null,
|
_confirmTimer: null,
|
||||||
_confirmAction: null,
|
_confirmAction: null,
|
||||||
|
|
||||||
@@ -109,6 +163,27 @@ const KeyboardAccessoryBar = {
|
|||||||
case 'scroll-down':
|
case 'scroll-down':
|
||||||
this.sendKey('\x1b[B');
|
this.sendKey('\x1b[B');
|
||||||
break;
|
break;
|
||||||
|
case 'arrow-left':
|
||||||
|
this.sendKey('\x1b[D');
|
||||||
|
break;
|
||||||
|
case 'arrow-right':
|
||||||
|
this.sendKey('\x1b[C');
|
||||||
|
break;
|
||||||
|
case 'esc':
|
||||||
|
this.sendKey('\x1b');
|
||||||
|
break;
|
||||||
|
case 'opt-enter':
|
||||||
|
this.sendKey('\x1b\r');
|
||||||
|
break;
|
||||||
|
case 'tab':
|
||||||
|
this.sendKey('\t');
|
||||||
|
break;
|
||||||
|
case 'shift-tab':
|
||||||
|
this.sendKey('\x1b[Z');
|
||||||
|
break;
|
||||||
|
case 'ctrl-o':
|
||||||
|
this.sendKey('\x0f');
|
||||||
|
break;
|
||||||
case 'init':
|
case 'init':
|
||||||
this.sendCommand('/init');
|
this.sendCommand('/init');
|
||||||
break;
|
break;
|
||||||
|
|||||||
+47
-31
@@ -886,7 +886,8 @@ html.mobile-init .file-browser-panel {
|
|||||||
padding-right: calc(8px + var(--safe-area-right));
|
padding-right: calc(8px + var(--safe-area-right));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
z-index: 51;
|
z-index: 51;
|
||||||
transition: transform 0.15s ease-out;
|
transition: transform 0.15s ease-out;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
@@ -896,10 +897,16 @@ html.mobile-init .file-browser-panel {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 隐藏滚动条但保留滑动能力 */
|
||||||
|
.keyboard-accessory-bar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.accessory-btn {
|
.accessory-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: #2a2a2a;
|
background: #2a2a2a;
|
||||||
@@ -930,31 +937,33 @@ html.mobile-init .file-browser-panel {
|
|||||||
|
|
||||||
.accessory-btn-arrow {
|
.accessory-btn-arrow {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: #1e3a5f;
|
background: #2563eb;
|
||||||
border-color: rgba(59, 130, 246, 0.3);
|
border-color: rgba(59, 130, 246, 0.5);
|
||||||
color: #93c5fd;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accessory-btn-arrow:active {
|
.accessory-btn-arrow:active {
|
||||||
background: #2563eb;
|
background: #1d4ed8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accessory-btn-dismiss {
|
.accessory-btn-dismiss {
|
||||||
padding: 8px 14px;
|
margin-left: auto;
|
||||||
background: #2a2a2a;
|
flex: 1 1 0;
|
||||||
border: 1.5px solid rgba(255, 255, 255, 0.25);
|
max-width: 100px;
|
||||||
border-radius: 6px;
|
padding: 10px 8px;
|
||||||
color: #e5e5e5;
|
background: #2563eb;
|
||||||
|
border-color: rgba(59, 130, 246, 0.5);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accessory-btn-dismiss svg {
|
.accessory-btn-dismiss svg {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
stroke-width: 3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.accessory-btn-dismiss:active {
|
.accessory-btn-dismiss:active {
|
||||||
background: #3a3a3a;
|
background: #1d4ed8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Voice preview — positioned above accessory bar on mobile */
|
/* Voice preview — positioned above accessory bar on mobile */
|
||||||
@@ -986,9 +995,9 @@ html.mobile-init .file-browser-panel {
|
|||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-bottom: env(safe-area-inset-bottom, 12px);
|
padding-top: 15vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paste-dialog {
|
.paste-dialog {
|
||||||
@@ -998,13 +1007,12 @@ html.mobile-init .file-browser-panel {
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
width: calc(100% - 24px);
|
width: calc(100% - 24px);
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.paste-textarea {
|
.paste-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 60px;
|
min-height: 80px;
|
||||||
max-height: 120px;
|
max-height: 200px;
|
||||||
background: var(--bg-primary, #0d0d14);
|
background: var(--bg-primary, #0d0d14);
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
border: 1px solid var(--border-color, #444);
|
border: 1px solid var(--border-color, #444);
|
||||||
@@ -2068,7 +2076,8 @@ html.mobile-init .file-browser-panel {
|
|||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
z-index: 51;
|
z-index: 51;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2076,10 +2085,15 @@ html.mobile-init .file-browser-panel {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.keyboard-accessory-bar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.accessory-btn {
|
.accessory-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: #2a2a2a;
|
background: #2a2a2a;
|
||||||
@@ -2109,31 +2123,33 @@ html.mobile-init .file-browser-panel {
|
|||||||
|
|
||||||
.accessory-btn-arrow {
|
.accessory-btn-arrow {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: #1e3a5f;
|
background: #2563eb;
|
||||||
border-color: rgba(59, 130, 246, 0.3);
|
border-color: rgba(59, 130, 246, 0.5);
|
||||||
color: #93c5fd;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accessory-btn-arrow:active {
|
.accessory-btn-arrow:active {
|
||||||
background: #2563eb;
|
background: #1d4ed8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accessory-btn-dismiss {
|
.accessory-btn-dismiss {
|
||||||
padding: 8px 14px;
|
margin-left: auto;
|
||||||
background: #2a2a2a;
|
flex: 1 1 0;
|
||||||
border: 1.5px solid rgba(255, 255, 255, 0.25);
|
max-width: 80px;
|
||||||
border-radius: 6px;
|
padding: 10px 8px;
|
||||||
color: #e5e5e5;
|
background: #334d6e;
|
||||||
|
border-color: rgba(100, 150, 200, 0.4);
|
||||||
|
color: #c0d4e8;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accessory-btn-dismiss svg {
|
.accessory-btn-dismiss svg {
|
||||||
width: 22px;
|
width: 20px;
|
||||||
height: 22px;
|
height: 20px;
|
||||||
stroke-width: 3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.accessory-btn-dismiss:active {
|
.accessory-btn-dismiss:active {
|
||||||
background: #3a3a3a;
|
background: #3d5f85;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
|
|||||||
@@ -1038,13 +1038,19 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
modal.querySelectorAll('.modal-tab-content').forEach(content => {
|
modal.querySelectorAll('.modal-tab-content').forEach(content => {
|
||||||
content.classList.toggle('hidden', content.id !== tabName);
|
content.classList.toggle('hidden', content.id !== tabName);
|
||||||
});
|
});
|
||||||
// Update submit button text
|
// Update submit button (hide for manage tab)
|
||||||
const submitBtn = document.getElementById('caseModalSubmit');
|
const submitBtn = document.getElementById('caseModalSubmit');
|
||||||
submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link';
|
if (tabName === 'case-manage') {
|
||||||
|
submitBtn.style.display = 'none';
|
||||||
|
this.renderCaseManageList();
|
||||||
|
} else {
|
||||||
|
submitBtn.style.display = '';
|
||||||
|
submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link';
|
||||||
|
}
|
||||||
// Focus appropriate input
|
// Focus appropriate input
|
||||||
if (tabName === 'case-create') {
|
if (tabName === 'case-create') {
|
||||||
document.getElementById('newCaseName').focus();
|
document.getElementById('newCaseName').focus();
|
||||||
} else {
|
} else if (tabName === 'case-link') {
|
||||||
document.getElementById('linkCaseName').focus();
|
document.getElementById('linkCaseName').focus();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1152,6 +1158,107 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Case Management (reorder + delete)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
renderCaseManageList() {
|
||||||
|
const container = document.getElementById('caseManageList');
|
||||||
|
const cases = this.cases || [];
|
||||||
|
if (cases.length === 0) {
|
||||||
|
container.innerHTML = '<div class="form-hint" style="text-align: center; padding: 2rem 0;">No cases yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
cases.forEach((c, idx) => {
|
||||||
|
const isFirst = idx === 0;
|
||||||
|
const isLast = idx === cases.length - 1;
|
||||||
|
const pathDisplay = c.path ? c.path.replace(/^\/Users\/[^/]+/, '~') : '';
|
||||||
|
html += `
|
||||||
|
<div class="case-manage-item" data-case="${escapeHtml(c.name)}">
|
||||||
|
<div class="case-manage-info">
|
||||||
|
<span class="case-manage-name">${escapeHtml(c.name)}</span>
|
||||||
|
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="case-manage-actions">
|
||||||
|
<button class="case-manage-btn" onclick="app.moveCaseUp('${escapeHtml(c.name)}')"
|
||||||
|
title="Move up" ${isFirst ? 'disabled' : ''}>▲</button>
|
||||||
|
<button class="case-manage-btn" onclick="app.moveCaseDown('${escapeHtml(c.name)}')"
|
||||||
|
title="Move down" ${isLast ? 'disabled' : ''}>▼</button>
|
||||||
|
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase('${escapeHtml(c.name)}')"
|
||||||
|
title="Delete case">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
container.innerHTML = html;
|
||||||
|
},
|
||||||
|
|
||||||
|
async moveCaseUp(name) {
|
||||||
|
const cases = this.cases || [];
|
||||||
|
const idx = cases.findIndex(c => c.name === name);
|
||||||
|
if (idx <= 0) return;
|
||||||
|
// Swap positions (immutable)
|
||||||
|
const reordered = [...cases];
|
||||||
|
[reordered[idx - 1], reordered[idx]] = [reordered[idx], reordered[idx - 1]];
|
||||||
|
this.cases = reordered;
|
||||||
|
this.renderCaseManageList();
|
||||||
|
await this.saveCaseOrder(reordered.map(c => c.name));
|
||||||
|
},
|
||||||
|
|
||||||
|
async moveCaseDown(name) {
|
||||||
|
const cases = this.cases || [];
|
||||||
|
const idx = cases.findIndex(c => c.name === name);
|
||||||
|
if (idx < 0 || idx >= cases.length - 1) return;
|
||||||
|
const reordered = [...cases];
|
||||||
|
[reordered[idx], reordered[idx + 1]] = [reordered[idx + 1], reordered[idx]];
|
||||||
|
this.cases = reordered;
|
||||||
|
this.renderCaseManageList();
|
||||||
|
await this.saveCaseOrder(reordered.map(c => c.name));
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteCase(name) {
|
||||||
|
if (!confirm(`Delete case "${name}"? Linked cases will only be unlinked (folder preserved). Created cases will be permanently deleted.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/cases/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.showToast(`Case "${name}" ${data.data?.type === 'unlinked' ? 'unlinked' : 'deleted'}`, 'success');
|
||||||
|
// Remove from current list and refresh
|
||||||
|
this.cases = (this.cases || []).filter(c => c.name !== name);
|
||||||
|
this.renderCaseManageList();
|
||||||
|
// Refresh the dropdown
|
||||||
|
const select = document.getElementById('quickStartCase');
|
||||||
|
const currentCase = select.value;
|
||||||
|
await this.loadQuickStartCases(currentCase === name ? null : currentCase);
|
||||||
|
} else {
|
||||||
|
this.showToast(data.error || 'Failed to delete case', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.showToast('Failed to delete case: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveCaseOrder(order) {
|
||||||
|
try {
|
||||||
|
await fetch('/api/cases/order', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ order })
|
||||||
|
});
|
||||||
|
// Refresh dropdown to reflect new order
|
||||||
|
const select = document.getElementById('quickStartCase');
|
||||||
|
const currentCase = select.value;
|
||||||
|
await this.loadQuickStartCases(currentCase);
|
||||||
|
} catch (err) {
|
||||||
|
this.showToast('Failed to save case order: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Mobile Case Picker
|
// Mobile Case Picker
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -1181,6 +1288,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span class="mobile-case-item-name">${escapeHtml(c.name)}</span>
|
<span class="mobile-case-item-name">${escapeHtml(c.name)}</span>
|
||||||
|
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile('${escapeHtml(c.name)}')" title="Delete">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span class="mobile-case-item-check">
|
<span class="mobile-case-item-check">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
<polyline points="20 6 9 17 4 12"/>
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
@@ -1226,6 +1338,25 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async deleteCaseMobile(name) {
|
||||||
|
if (!confirm(`Delete case "${name}"?`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/cases/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.showToast(`Case "${name}" ${data.data?.type === 'unlinked' ? 'unlinked' : 'deleted'}`, 'success');
|
||||||
|
this.cases = (this.cases || []).filter(c => c.name !== name);
|
||||||
|
// Refresh mobile picker and dropdown
|
||||||
|
this.closeMobileCasePicker();
|
||||||
|
await this.loadQuickStartCases();
|
||||||
|
} else {
|
||||||
|
this.showToast(data.error || 'Failed to delete case', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.showToast('Failed to delete case: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
showCreateCaseFromMobile() {
|
showCreateCaseFromMobile() {
|
||||||
// Close mobile picker first
|
// Close mobile picker first
|
||||||
this.closeMobileCasePicker();
|
this.closeMobileCasePicker();
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.loadTunnelStatus();
|
this.loadTunnelStatus();
|
||||||
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false;
|
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false;
|
||||||
|
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||||
// Claude CLI settings
|
// Claude CLI settings
|
||||||
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
||||||
@@ -1125,6 +1126,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
||||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||||
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
||||||
|
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||||
// Claude CLI settings
|
// Claude CLI settings
|
||||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||||
@@ -1245,9 +1247,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Apply CJK input visibility immediately
|
// Apply CJK input visibility immediately
|
||||||
this._updateCjkInputState();
|
this._updateCjkInputState();
|
||||||
|
|
||||||
|
// Apply keyboard bar mode
|
||||||
|
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
|
||||||
|
|
||||||
// Save to server (includes notification prefs for cross-browser persistence)
|
// Save to server (includes notification prefs for cross-browser persistence)
|
||||||
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
|
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
|
||||||
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, ...serverSettings } = settings;
|
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, ...serverSettings } = settings;
|
||||||
try {
|
try {
|
||||||
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
|
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
|
||||||
|
|
||||||
@@ -1278,7 +1283,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Default model
|
// Default model
|
||||||
const defaultModelEl = document.getElementById('appSettingsDefaultModel');
|
const defaultModelEl = document.getElementById('appSettingsDefaultModel');
|
||||||
if (defaultModelEl) {
|
if (defaultModelEl) {
|
||||||
defaultModelEl.value = config.defaultModel || 'opus';
|
defaultModelEl.value = config.defaultModel || '';
|
||||||
}
|
}
|
||||||
// Show recommendations
|
// Show recommendations
|
||||||
const showRecsEl = document.getElementById('appSettingsShowModelRecommendations');
|
const showRecsEl = document.getElementById('appSettingsShowModelRecommendations');
|
||||||
@@ -1317,7 +1322,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (reviewEl?.value) agentTypeOverrides.review = reviewEl.value;
|
if (reviewEl?.value) agentTypeOverrides.review = reviewEl.value;
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
defaultModel: defaultModelEl?.value || 'opus',
|
defaultModel: defaultModelEl?.value || '',
|
||||||
showRecommendations: showRecsEl?.checked ?? true,
|
showRecommendations: showRecsEl?.checked ?? true,
|
||||||
agentTypeOverrides,
|
agentTypeOverrides,
|
||||||
};
|
};
|
||||||
@@ -1631,7 +1636,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const displayKeys = new Set([
|
const displayKeys = new Set([
|
||||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled',
|
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||||
]);
|
]);
|
||||||
// Merge settings: non-display keys always sync from server,
|
// Merge settings: non-display keys always sync from server,
|
||||||
// display keys only seed from server when localStorage has no value
|
// display keys only seed from server when localStorage has no value
|
||||||
|
|||||||
@@ -2964,6 +2964,93 @@ body {
|
|||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Case Management List (Manage tab) */
|
||||||
|
.case-manage-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-manage-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-manage-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-manage-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-manage-name {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-manage-path {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-manage-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-manage-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-manage-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-manage-btn:disabled {
|
||||||
|
opacity: 0.25;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-manage-btn-delete:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-input {
|
.toolbar-input {
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.4rem 0.5rem;
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
@@ -3692,6 +3779,25 @@ body {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-case-item-delete {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.4;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-case-item-delete:active {
|
||||||
|
opacity: 1;
|
||||||
|
color: #ef4444;
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-case-picker-footer {
|
.mobile-case-picker-footer {
|
||||||
padding: 12px 20px 16px;
|
padding: 12px 20px 16px;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Accumulate sub-line pixel deltas so slow swipes still scroll
|
// Accumulate sub-line pixel deltas so slow swipes still scroll
|
||||||
let pixelAccum = 0;
|
let pixelAccum = 0;
|
||||||
|
|
||||||
|
let didScroll = false; // track whether touchmove fired (tap vs scroll)
|
||||||
container.addEventListener(
|
container.addEventListener(
|
||||||
'touchstart',
|
'touchstart',
|
||||||
(ev) => {
|
(ev) => {
|
||||||
@@ -187,6 +188,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
velocity = 0;
|
velocity = 0;
|
||||||
pixelAccum = 0;
|
pixelAccum = 0;
|
||||||
isTouching = true;
|
isTouching = true;
|
||||||
|
didScroll = false;
|
||||||
lastTime = 0;
|
lastTime = 0;
|
||||||
if (scrollFrame) {
|
if (scrollFrame) {
|
||||||
cancelAnimationFrame(scrollFrame);
|
cancelAnimationFrame(scrollFrame);
|
||||||
@@ -201,6 +203,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
'touchmove',
|
'touchmove',
|
||||||
(ev) => {
|
(ev) => {
|
||||||
if (ev.touches.length === 1 && isTouching) {
|
if (ev.touches.length === 1 && isTouching) {
|
||||||
|
didScroll = true;
|
||||||
const touchY = ev.touches[0].clientY;
|
const touchY = ev.touches[0].clientY;
|
||||||
const delta = touchLastY - touchY; // positive = scroll down
|
const delta = touchLastY - touchY; // positive = scroll down
|
||||||
pixelAccum += delta;
|
pixelAccum += delta;
|
||||||
@@ -225,6 +228,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (!scrollFrame && Math.abs(velocity) > 0.3) {
|
if (!scrollFrame && Math.abs(velocity) > 0.3) {
|
||||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
scrollFrame = requestAnimationFrame(scrollLoop);
|
||||||
}
|
}
|
||||||
|
// Tap (no scroll): refocus xterm's hidden textarea so keyboard input
|
||||||
|
// routes back to the terminal. Without this, a tap on the terminal area
|
||||||
|
// consumes the touch event but xterm's textarea never regains focus.
|
||||||
|
if (!didScroll && this.terminal) {
|
||||||
|
this.terminal.focus();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ passive: true }
|
{ passive: true }
|
||||||
);
|
);
|
||||||
@@ -284,22 +293,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
this.flushFlickerBuffer();
|
this.flushFlickerBuffer();
|
||||||
}
|
}
|
||||||
// Clear viewport + scrollback for Ink-based sessions before sending SIGWINCH.
|
|
||||||
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
|
|
||||||
// pushing overflow into scrollback. Ink's cursor-up count is based on the
|
|
||||||
// pre-reflow line count, so ghost renders accumulate in scrollback.
|
|
||||||
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
|
|
||||||
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
|
|
||||||
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
|
||||||
if (
|
|
||||||
activeResizeSession &&
|
|
||||||
activeResizeSession.mode !== 'shell' &&
|
|
||||||
!activeResizeSession._ended &&
|
|
||||||
this.terminal &&
|
|
||||||
this.isTerminalAtBottom()
|
|
||||||
) {
|
|
||||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
|
||||||
}
|
|
||||||
// Skip server resize while mobile keyboard is visible — sending SIGWINCH
|
// Skip server resize while mobile keyboard is visible — sending SIGWINCH
|
||||||
// causes Ink to re-render at the new row count, garbling terminal output.
|
// causes Ink to re-render at the new row count, garbling terminal output.
|
||||||
// Local fit() still runs so xterm knows the viewport size for scrolling.
|
// Local fit() still runs so xterm knows the viewport size for scrolling.
|
||||||
@@ -311,6 +304,24 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS;
|
const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS;
|
||||||
// Only send resize if dimensions actually changed
|
// Only send resize if dimensions actually changed
|
||||||
if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) {
|
if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) {
|
||||||
|
// Clear viewport + scrollback ONLY when dimensions actually change.
|
||||||
|
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
|
||||||
|
// pushing overflow into scrollback. Ink's cursor-up count is based on the
|
||||||
|
// pre-reflow line count, so ghost renders accumulate in scrollback.
|
||||||
|
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
|
||||||
|
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
|
||||||
|
// IMPORTANT: Only clear when we're actually sending SIGWINCH (dims changed).
|
||||||
|
// Clearing without a subsequent Ink redraw leaves the terminal blank.
|
||||||
|
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||||
|
if (
|
||||||
|
activeResizeSession &&
|
||||||
|
activeResizeSession.mode !== 'shell' &&
|
||||||
|
!activeResizeSession._ended &&
|
||||||
|
this.terminal &&
|
||||||
|
this.isTerminalAtBottom()
|
||||||
|
) {
|
||||||
|
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||||
|
}
|
||||||
this._lastResizeDims = { cols, rows };
|
this._lastResizeDims = { cols, rows };
|
||||||
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
|
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1348,6 +1359,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (this.fitAddon) this.fitAddon.fit();
|
if (this.fitAddon) this.fitAddon.fit();
|
||||||
const dims = this.getTerminalDimensions();
|
const dims = this.getTerminalDimensions();
|
||||||
if (!dims) return;
|
if (!dims) return;
|
||||||
|
// Update _lastResizeDims so the throttledResize handler won't redundantly
|
||||||
|
// clear the terminal for the same dimensions (which would blank the screen
|
||||||
|
// without a subsequent Ink redraw to repaint it).
|
||||||
|
this._lastResizeDims = { cols: dims.cols, rows: dims.rows };
|
||||||
// Fast path: WebSocket resize
|
// Fast path: WebSocket resize
|
||||||
if (this._wsReady && this._wsSessionId === sessionId) {
|
if (this._wsReady && this._wsSessionId === sessionId) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ import { join, resolve } from 'node:path';
|
|||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import type { ApiResponse, CaseInfo } from '../../types.js';
|
import type { ApiResponse, CaseInfo } from '../../types.js';
|
||||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||||
import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js';
|
import { CreateCaseSchema, LinkCaseSchema, CaseOrderSchema } from '../schemas.js';
|
||||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
import { writeHooksConfig } from '../../hooks-config.js';
|
import { writeHooksConfig } from '../../hooks-config.js';
|
||||||
import { CASES_DIR, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||||
|
|
||||||
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
|
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
|
||||||
|
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||||
|
|
||||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||||
async function readLinkedCases(): Promise<Record<string, string>> {
|
async function readLinkedCases(): Promise<Record<string, string>> {
|
||||||
@@ -46,7 +47,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(CASES_DIR, { withFileTypes: true });
|
const entries = await fs.readdir(CASES_DIR, { withFileTypes: true });
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
if (e.isDirectory()) {
|
if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
|
||||||
cases.push({
|
cases.push({
|
||||||
name: e.name,
|
name: e.name,
|
||||||
path: join(CASES_DIR, e.name),
|
path: join(CASES_DIR, e.name),
|
||||||
@@ -62,7 +63,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
const linkedCases = await readLinkedCases();
|
const linkedCases = await readLinkedCases();
|
||||||
const existingNames = new Set(cases.map((c) => c.name));
|
const existingNames = new Set(cases.map((c) => c.name));
|
||||||
for (const [name, path] of Object.entries(linkedCases)) {
|
for (const [name, path] of Object.entries(linkedCases)) {
|
||||||
if (!existingNames.has(name) && existsSync(path)) {
|
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
|
||||||
cases.push({
|
cases.push({
|
||||||
name,
|
name,
|
||||||
path,
|
path,
|
||||||
@@ -71,6 +72,18 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort by persisted caseOrder from settings.json
|
||||||
|
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings', {});
|
||||||
|
const caseOrder = Array.isArray(settings.caseOrder) ? (settings.caseOrder as string[]) : [];
|
||||||
|
if (caseOrder.length > 0) {
|
||||||
|
const orderMap = new Map(caseOrder.map((name, idx) => [name, idx]));
|
||||||
|
cases.sort((a, b) => {
|
||||||
|
const ai = orderMap.get(a.name) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const bi = orderMap.get(b.name) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
return ai - bi;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return cases;
|
return cases;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,6 +163,68 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== Delete / Unlink Case ==========
|
||||||
|
|
||||||
|
app.delete('/api/cases/:name', async (req): Promise<ApiResponse<{ name: string }>> => {
|
||||||
|
const { name } = req.params as { name: string };
|
||||||
|
|
||||||
|
if (!validatePathWithinBase(name, CASES_DIR)) {
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check linked cases first — unlink only, don't delete the actual directory
|
||||||
|
const linkedCases = await readLinkedCases();
|
||||||
|
if (linkedCases[name]) {
|
||||||
|
delete linkedCases[name];
|
||||||
|
try {
|
||||||
|
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
|
||||||
|
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'unlinked' });
|
||||||
|
return { success: true, data: { name } };
|
||||||
|
} catch (err) {
|
||||||
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case in CASES_DIR — delete the entire directory
|
||||||
|
const casePath = join(CASES_DIR, name);
|
||||||
|
if (!existsSync(casePath)) {
|
||||||
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.rm(casePath, { recursive: true, force: true });
|
||||||
|
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'deleted' });
|
||||||
|
return { success: true, data: { name } };
|
||||||
|
} catch (err) {
|
||||||
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Reorder Cases ==========
|
||||||
|
|
||||||
|
app.put('/api/cases/order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
|
||||||
|
const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dir = join(homedir(), '.codeman');
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
let existing: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
const merged = { ...existing, caseOrder: order };
|
||||||
|
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||||
|
ctx.broadcast(SseEvent.CaseOrderChanged, { order });
|
||||||
|
return { success: true, data: { order } };
|
||||||
|
} catch (err) {
|
||||||
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/cases/:name', async (req) => {
|
app.get('/api/cases/:name', async (req) => {
|
||||||
const { name } = req.params as { name: string };
|
const { name } = req.params as { name: string };
|
||||||
|
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ export function registerRalphRoutes(
|
|||||||
useMux: true,
|
useMux: true,
|
||||||
mode: 'claude',
|
mode: 'claude',
|
||||||
niceConfig,
|
niceConfig,
|
||||||
model: rlModelConfig?.defaultModel,
|
model: rlModelConfig?.defaultModel || undefined,
|
||||||
claudeMode: rlClaudeModeConfig.claudeMode,
|
claudeMode: rlClaudeModeConfig.claudeMode,
|
||||||
allowedTools: rlClaudeModeConfig.allowedTools,
|
allowedTools: rlClaudeModeConfig.allowedTools,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -186,7 +186,11 @@ export function registerSessionRoutes(
|
|||||||
const modelConfig = await ctx.getModelConfig();
|
const modelConfig = await ctx.getModelConfig();
|
||||||
const mode = body.mode || 'claude';
|
const mode = body.mode || 'claude';
|
||||||
const model =
|
const model =
|
||||||
mode === 'opencode' ? body.openCodeConfig?.model : mode !== 'shell' ? modelConfig?.defaultModel : undefined;
|
mode === 'opencode'
|
||||||
|
? body.openCodeConfig?.model
|
||||||
|
: mode !== 'shell'
|
||||||
|
? modelConfig?.defaultModel || undefined
|
||||||
|
: undefined;
|
||||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir,
|
workingDir,
|
||||||
@@ -766,7 +770,11 @@ export function registerSessionRoutes(
|
|||||||
const niceConfig = await ctx.getGlobalNiceConfig();
|
const niceConfig = await ctx.getGlobalNiceConfig();
|
||||||
const qsModelConfig = await ctx.getModelConfig();
|
const qsModelConfig = await ctx.getModelConfig();
|
||||||
const qsModel =
|
const qsModel =
|
||||||
mode === 'opencode' ? openCodeConfig?.model : mode !== 'shell' ? qsModelConfig?.defaultModel : undefined;
|
mode === 'opencode'
|
||||||
|
? openCodeConfig?.model
|
||||||
|
: mode !== 'shell'
|
||||||
|
? qsModelConfig?.defaultModel || undefined
|
||||||
|
: undefined;
|
||||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir: casePath,
|
workingDir: casePath,
|
||||||
|
|||||||
@@ -431,6 +431,11 @@ export const LinkCaseSchema = z.object({
|
|||||||
path: safePathSchema,
|
path: safePathSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** PUT /api/cases/order */
|
||||||
|
export const CaseOrderSchema = z.object({
|
||||||
|
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
|
||||||
|
});
|
||||||
|
|
||||||
/** POST /api/auth/revoke */
|
/** POST /api/auth/revoke */
|
||||||
export const RevokeSessionSchema = z.object({
|
export const RevokeSessionSchema = z.object({
|
||||||
sessionToken: z.string().min(1).max(200).optional(),
|
sessionToken: z.string().min(1).max(200).optional(),
|
||||||
|
|||||||
+23
-9
@@ -544,7 +544,8 @@ export class WebServer extends EventEmitter {
|
|||||||
cacheControl: false,
|
cacheControl: false,
|
||||||
preCompressed: true,
|
preCompressed: true,
|
||||||
setHeaders: (res, path) => {
|
setHeaders: (res, path) => {
|
||||||
if (path.endsWith('.html')) {
|
// Use .includes() not .endsWith() — preCompressed serves .html.br/.html.gz
|
||||||
|
if (path.includes('.html')) {
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
} else {
|
} else {
|
||||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
@@ -1766,15 +1767,28 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
this.sessions.set(session.id, session);
|
this.sessions.set(session.id, session);
|
||||||
await this.setupSessionListeners(session);
|
await this.setupSessionListeners(session);
|
||||||
this.persistSessionState(session);
|
|
||||||
|
|
||||||
// Mark it as restored (not started yet - user needs to attach)
|
// Auto-attach PTY to the surviving tmux session immediately.
|
||||||
getLifecycleLog().log({
|
// This ensures ALL sessions resume capturing output right away,
|
||||||
event: 'recovered',
|
// not just the one the client happens to select first.
|
||||||
sessionId: session.id,
|
try {
|
||||||
name: session.name,
|
await session.startInteractive();
|
||||||
});
|
getLifecycleLog().log({
|
||||||
console.log(`[Server] Restored session ${session.id} from mux ${muxSession.muxName}`);
|
event: 'recovered',
|
||||||
|
sessionId: session.id,
|
||||||
|
name: session.name,
|
||||||
|
});
|
||||||
|
console.log(`[Server] Restored and attached session ${session.id} from mux ${muxSession.muxName}`);
|
||||||
|
} catch (attachErr) {
|
||||||
|
console.error(`[Server] Failed to attach session ${session.id}, keeping as detached:`, attachErr);
|
||||||
|
getLifecycleLog().log({
|
||||||
|
event: 'recovered',
|
||||||
|
sessionId: session.id,
|
||||||
|
name: session.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.persistSessionState(session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -325,6 +325,10 @@ export const OrchestratorError = 'orchestrator:error' as const;
|
|||||||
export const CaseCreated = 'case:created' as const;
|
export const CaseCreated = 'case:created' as const;
|
||||||
/** Existing directory linked as a case. */
|
/** Existing directory linked as a case. */
|
||||||
export const CaseLinked = 'case:linked' as const;
|
export const CaseLinked = 'case:linked' as const;
|
||||||
|
/** Case deleted or unlinked. */
|
||||||
|
export const CaseDeleted = 'case:deleted' as const;
|
||||||
|
/** Case ordering changed. */
|
||||||
|
export const CaseOrderChanged = 'case:order-changed' as const;
|
||||||
|
|
||||||
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -485,4 +489,6 @@ export const SseEvent = {
|
|||||||
// Cases
|
// Cases
|
||||||
CaseCreated,
|
CaseCreated,
|
||||||
CaseLinked,
|
CaseLinked,
|
||||||
|
CaseDeleted,
|
||||||
|
CaseOrderChanged,
|
||||||
} as const;
|
} as const;
|
||||||
Reference in new issue
Block a user