mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-25 02:19:33 +01:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89d787a949 | ||
|
|
bd9797b68c | ||
|
|
0e6cd94312 | ||
|
|
24a6f1cac8 | ||
|
|
8e679a280b | ||
|
|
c642689bbd | ||
|
|
28a6247c27 | ||
|
|
0ceb455c4b | ||
|
|
e51117dfa9 | ||
|
|
13d41cf7c7 | ||
|
|
2c7557d002 | ||
|
|
53b473708f | ||
|
|
2cba393ae5 | ||
|
|
64b8ea30b2 | ||
|
|
5743af3339 | ||
|
|
2011bd8d89 | ||
|
|
b76724690d | ||
|
|
f277f9664c | ||
|
|
cd49171bbc | ||
|
|
0f57342b10 | ||
|
|
e1f0ac993a | ||
|
|
a84ef52992 | ||
|
|
692c894760 | ||
|
|
ad0acb6d58 |
No files matched your search
@@ -1,5 +1,43 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- fix: default new sessions to opus[1m] (1M context window) instead of plain opus (200k context)
|
||||||
|
|
||||||
|
## 0.5.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Add 1M Opus context quick setting — per-case and global toggle that writes `model: "opus[1m]"` to `.claude/settings.local.json` when creating new sessions. Fix mobile layout: banners (respawn, timer, orchestrator) between header and main content now visible by switching from margin-top on `.main` to padding-top on `.app`. Add tablet-optimized respawn banner styles and mobile phone banner refinements.
|
||||||
|
|
||||||
## 0.5.4
|
## 0.5.4
|
||||||
|
|
||||||
### 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.4 (must match `package.json`)
|
**Version**: 0.5.8 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
+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.4",
|
"version": "0.5.8",
|
||||||
"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",
|
||||||
|
|||||||
@@ -116,6 +116,34 @@ export async function updateCaseEnvVars(casePath: string, envVars: Record<string
|
|||||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the `model` field in .claude/settings.local.json for the given case path.
|
||||||
|
* Pass a non-empty string to set, or empty/null to remove.
|
||||||
|
*/
|
||||||
|
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
|
||||||
|
const claudeDir = join(casePath, '.claude');
|
||||||
|
if (!existsSync(claudeDir)) {
|
||||||
|
await mkdir(claudeDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||||
|
let existing: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
existing = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model) {
|
||||||
|
existing.model = model;
|
||||||
|
} else {
|
||||||
|
delete existing.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes hooks config to .claude/settings.local.json in the given case path.
|
* Writes hooks config to .claude/settings.local.json in the given case path.
|
||||||
* Merges with existing file content, only touching the `hooks` key.
|
* Merges with existing file content, only touching the `hooks` key.
|
||||||
|
|||||||
@@ -2078,6 +2078,18 @@ export class RespawnController extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for active child processes (bash tools, test suites, builds, etc.)
|
||||||
|
// These may produce no terminal output, so restart timers to retry periodically.
|
||||||
|
const activeProcesses = this.session.getActiveChildProcesses();
|
||||||
|
if (activeProcesses.length > 0) {
|
||||||
|
const names = activeProcesses.map((p) => p.command).join(', ');
|
||||||
|
this.log(`Skipping AI check - ${activeProcesses.length} active child process(es): ${names}`);
|
||||||
|
this.logAction('detection', `Skipped AI check: child processes running (${names})`);
|
||||||
|
this.startNoOutputTimer();
|
||||||
|
this.startPreFilterTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If AI check is disabled or errored out, fall back to direct idle confirmation
|
// If AI check is disabled or errored out, fall back to direct idle confirmation
|
||||||
if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') {
|
if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') {
|
||||||
this.log(`AI check unavailable (${this.aiChecker.status}), confirming idle directly via: ${reason}`);
|
this.log(`AI check unavailable (${this.aiChecker.status}), confirming idle directly via: ${reason}`);
|
||||||
@@ -2682,6 +2694,18 @@ export class RespawnController extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safety check: if child processes are running (bash tools, test suites, builds, etc.)
|
||||||
|
const activeProcesses = this.session.getActiveChildProcesses();
|
||||||
|
if (activeProcesses.length > 0) {
|
||||||
|
const names = activeProcesses.map((p) => p.command).join(', ');
|
||||||
|
this.log(`Idle confirmation rejected - ${activeProcesses.length} active child process(es): ${names}`);
|
||||||
|
this.logAction('detection', `Rejected: child processes running (${names})`);
|
||||||
|
this.setState('watching');
|
||||||
|
this.startNoOutputTimer();
|
||||||
|
this.startPreFilterTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.log(`Idle confirmed via: ${reason}`);
|
this.log(`Idle confirmed via: ${reason}`);
|
||||||
const status = this.getDetectionStatus();
|
const status = this.getDetectionStatus();
|
||||||
this.log(
|
this.log(
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as pty from 'node-pty';
|
import * as pty from 'node-pty';
|
||||||
import {
|
import {
|
||||||
@@ -65,6 +66,7 @@ import {
|
|||||||
MAX_MESSAGES,
|
MAX_MESSAGES,
|
||||||
MAX_LINE_BUFFER_SIZE,
|
MAX_LINE_BUFFER_SIZE,
|
||||||
} from './config/buffer-limits.js';
|
} from './config/buffer-limits.js';
|
||||||
|
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||||
import {
|
import {
|
||||||
buildInteractiveArgs,
|
buildInteractiveArgs,
|
||||||
buildPromptArgs,
|
buildPromptArgs,
|
||||||
@@ -272,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
|
||||||
@@ -539,6 +542,49 @@ export class Session extends EventEmitter {
|
|||||||
return this._isWorking;
|
return this._isWorking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the session's process tree has active child processes beyond Claude itself.
|
||||||
|
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
|
||||||
|
*
|
||||||
|
* The tmux pane PID is typically "claude" directly (bash exec'd into it). When Claude
|
||||||
|
* runs a bash tool, it spawns child processes: claude → bash → npm/node/python/etc.
|
||||||
|
* We check direct children of the pane PID, filtering out "claude" itself (for the rare
|
||||||
|
* case where bash wraps claude and didn't exec).
|
||||||
|
*
|
||||||
|
* Returns an array of {pid, command} for each child process, or empty array if none.
|
||||||
|
* Returns empty array if no mux session or on error (fail-open to avoid blocking respawn).
|
||||||
|
*/
|
||||||
|
getActiveChildProcesses(): { pid: number; command: string }[] {
|
||||||
|
if (!this._muxSession) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const panePid = this._muxSession.pid;
|
||||||
|
|
||||||
|
// Single call: get direct children with their command names
|
||||||
|
const output = execSync(`ps -o pid=,comm= --ppid ${panePid} 2>/dev/null`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
}).trim();
|
||||||
|
if (!output) return [];
|
||||||
|
|
||||||
|
const activeProcesses: { pid: number; command: string }[] = [];
|
||||||
|
for (const line of output.split('\n')) {
|
||||||
|
const match = line.trim().match(/^(\d+)\s+(.+)/);
|
||||||
|
if (!match) continue;
|
||||||
|
const pid = parseInt(match[1], 10);
|
||||||
|
const command = match[2].trim();
|
||||||
|
// Skip the claude process itself (pane_pid may be bash wrapping claude)
|
||||||
|
if (command === 'claude') continue;
|
||||||
|
activeProcesses.push({ pid, command });
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeProcesses;
|
||||||
|
} catch {
|
||||||
|
// ps returns exit code 1 when no matches — normal (no children)
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get lastPromptTime(): number {
|
get lastPromptTime(): number {
|
||||||
return this._lastPromptTime;
|
return this._lastPromptTime;
|
||||||
}
|
}
|
||||||
@@ -1073,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();
|
||||||
|
|||||||
@@ -2105,8 +2105,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',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -384,6 +384,11 @@
|
|||||||
<span>Agent Teams</span>
|
<span>Agent Teams</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="form-hint">Enable experimental Agent Teams for new sessions in this case</span>
|
<span class="form-hint">Enable experimental Agent Teams for new sessions in this case</span>
|
||||||
|
<label class="checkbox-inline" style="margin-top: 6px;">
|
||||||
|
<input type="checkbox" id="caseOpusContext1m" onchange="app.onCaseSettingChanged()">
|
||||||
|
<span>1M Opus Context</span>
|
||||||
|
</label>
|
||||||
|
<span class="form-hint">Use 1M token context window for new sessions</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Mobile-only case button + gear -->
|
<!-- Mobile-only case button + gear -->
|
||||||
@@ -408,6 +413,11 @@
|
|||||||
<span>Agent Teams</span>
|
<span>Agent Teams</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="form-hint">Enable Agent Teams for new sessions</span>
|
<span class="form-hint">Enable Agent Teams for new sessions</span>
|
||||||
|
<label class="checkbox-inline" style="margin-top: 6px;">
|
||||||
|
<input type="checkbox" id="caseOpusContext1mMobile" onchange="app.onCaseSettingChangedMobile()">
|
||||||
|
<span>1M Opus Context</span>
|
||||||
|
</label>
|
||||||
|
<span class="form-hint">Use 1M token context window</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1046,6 +1056,14 @@
|
|||||||
</label>
|
</label>
|
||||||
<span class="form-hint">Enable experimental Agent Teams for all new Claude sessions (disabled by default)</span>
|
<span class="form-hint">Enable experimental Agent Teams for all new Claude sessions (disabled by default)</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row form-row-switch">
|
||||||
|
<label>1M Opus Context</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="appSettingsOpusContext1m">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span class="form-hint">Use 1M token context window (model: opus[1m]) for all new sessions</span>
|
||||||
|
</div>
|
||||||
<!-- Nice Priority Section -->
|
<!-- Nice Priority Section -->
|
||||||
<div class="form-section-header">Nice Priority</div>
|
<div class="form-section-header">Nice Priority</div>
|
||||||
<div class="form-row form-row-switch">
|
<div class="form-row form-row-switch">
|
||||||
@@ -1067,6 +1085,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>
|
||||||
@@ -1341,6 +1360,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 -->
|
||||||
@@ -1368,6 +1388,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>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Defines two exports:
|
* Defines two exports:
|
||||||
*
|
*
|
||||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||||
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, and dismiss.
|
* keyboard on mobile: Esc, arrow up/down, Tab, Shift+Tab, Ctrl+O, /init, /clear, /compact, paste, and dismiss.
|
||||||
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
||||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||||
@@ -53,17 +53,32 @@ const KeyboardAccessoryBar = {
|
|||||||
<path d="M19 9l-7 7-7-7"/>
|
<path d="M19 9l-7 7-7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="accessory-btn" data-action="init" title="/init">/init</button>
|
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-left" title="Arrow left">
|
||||||
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
|
<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">
|
<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">
|
<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"/>
|
<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"/>
|
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
<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-arrow" data-action="dismiss" title="Dismiss keyboard">
|
||||||
|
<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"/>
|
<path d="M19 9l-7 7-7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -80,7 +95,7 @@ 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') ||
|
if ((action === 'scroll-up' || action === 'scroll-down' || action === 'arrow-left' || action === 'arrow-right' || action === 'tab' || action === 'shift-tab' || action === 'ctrl-o' || action === 'opt-enter' || action === 'esc') ||
|
||||||
((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();
|
||||||
@@ -109,6 +124,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;
|
||||||
|
|||||||
+159
-85
@@ -65,13 +65,14 @@ html.mobile-init .file-browser-panel {
|
|||||||
max-height: calc(48px + var(--safe-area-top));
|
max-height: calc(48px + var(--safe-area-top));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add top margin to main content to account for fixed header */
|
/* Push ALL content below fixed header (not just .main) so banners
|
||||||
.main {
|
(respawn, timer, orchestrator) between header and main are visible */
|
||||||
margin-top: 54px;
|
.app {
|
||||||
|
padding-top: 54px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-device .main {
|
.ios-device .app {
|
||||||
margin-top: calc(54px + var(--safe-area-top));
|
padding-top: calc(54px + var(--safe-area-top));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Font controls - smaller on tablet, visibility controlled by JS */
|
/* Font controls - smaller on tablet, visibility controlled by JS */
|
||||||
@@ -235,6 +236,37 @@ html.mobile-init .file-browser-panel {
|
|||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Respawn Banner: Tablet Optimizations ---- */
|
||||||
|
|
||||||
|
.respawn-banner {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
padding-left: calc(0.5rem + var(--safe-area-left));
|
||||||
|
padding-right: calc(0.5rem + var(--safe-area-right));
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.respawn-compact-layout {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.respawn-status-row1 {
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.respawn-action-log {
|
||||||
|
max-height: 2.6em;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.respawn-countdown-timer {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respawn timer bar — slimmer on tablet */
|
||||||
|
.respawn-timer-bar {
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Show desktop voice button on tablet (hidden by max-width:1023px in styles.css,
|
/* Show desktop voice button on tablet (hidden by max-width:1023px in styles.css,
|
||||||
mobile .btn-voice-mobile only shows at <430px) */
|
mobile .btn-voice-mobile only shows at <430px) */
|
||||||
.toolbar-center .btn-toolbar.btn-voice {
|
.toolbar-center .btn-toolbar.btn-voice {
|
||||||
@@ -327,18 +359,22 @@ html.mobile-init .file-browser-panel {
|
|||||||
max-height: calc(36px + var(--safe-area-top));
|
max-height: calc(36px + var(--safe-area-top));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add top margin to main content to account for fixed header,
|
/* Push ALL content below fixed header (not just .main) so banners
|
||||||
and bottom padding for the fixed toolbar (40px) so terminal
|
(respawn, timer, orchestrator) between header and main are visible.
|
||||||
content doesn't extend behind it. JS overrides paddingBottom
|
Bottom padding for the fixed toolbar (40px) so terminal content
|
||||||
|
doesn't extend behind it. JS overrides paddingBottom on .main
|
||||||
when keyboard is visible, and resetLayout() clears the inline
|
when keyboard is visible, and resetLayout() clears the inline
|
||||||
style to re-expose this CSS value. */
|
style to re-expose this CSS value. */
|
||||||
.main {
|
.app {
|
||||||
margin-top: 42px;
|
padding-top: 42px;
|
||||||
padding-bottom: calc(40px + var(--safe-area-bottom));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ios-device .main {
|
.ios-device .app {
|
||||||
margin-top: calc(42px + var(--safe-area-top));
|
padding-top: calc(42px + var(--safe-area-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding-bottom: calc(40px + var(--safe-area-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* iOS Safari: toolbar is pushed up by (100vh - --app-height) to clear the
|
/* iOS Safari: toolbar is pushed up by (100vh - --app-height) to clear the
|
||||||
@@ -850,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;
|
||||||
@@ -860,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;
|
||||||
@@ -903,24 +946,6 @@ html.mobile-init .file-browser-panel {
|
|||||||
background: #2563eb;
|
background: #2563eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accessory-btn-dismiss {
|
|
||||||
padding: 8px 14px;
|
|
||||||
background: #2a2a2a;
|
|
||||||
border: 1.5px solid rgba(255, 255, 255, 0.25);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #e5e5e5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accessory-btn-dismiss svg {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
stroke-width: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accessory-btn-dismiss:active {
|
|
||||||
background: #3a3a3a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Voice preview — positioned above accessory bar on mobile */
|
/* Voice preview — positioned above accessory bar on mobile */
|
||||||
.voice-preview {
|
.voice-preview {
|
||||||
bottom: calc(var(--safe-area-bottom) + 94px);
|
bottom: calc(var(--safe-area-bottom) + 94px);
|
||||||
@@ -1213,31 +1238,70 @@ html.mobile-init .file-browser-panel {
|
|||||||
/* ---- Respawn Banner: Mobile Optimizations ---- */
|
/* ---- Respawn Banner: Mobile Optimizations ---- */
|
||||||
|
|
||||||
.respawn-banner {
|
.respawn-banner {
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.3rem 0.5rem;
|
||||||
padding-left: calc(0.5rem + var(--safe-area-left));
|
padding-left: calc(0.5rem + var(--safe-area-left));
|
||||||
padding-right: calc(0.5rem + var(--safe-area-right));
|
padding-right: calc(0.5rem + var(--safe-area-right));
|
||||||
font-size: 0.7rem;
|
font-size: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stack banner vertically on mobile — status on top, action log below */
|
/* Stack banner vertically on mobile — status on top, action log below */
|
||||||
.respawn-compact-layout {
|
.respawn-compact-layout {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Let status column shrink to fit */
|
/* Let status column shrink to fit */
|
||||||
.respawn-status-col {
|
.respawn-status-col {
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
gap: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Wrap status row items — tokens/timer may go to second line */
|
/* Row1: wrap allowed, key info prioritized via order */
|
||||||
.respawn-status-row1 {
|
.respawn-status-row1 {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.35rem;
|
gap: 0.25rem;
|
||||||
|
row-gap: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stop button — 44px touch target */
|
/* State label — smaller on phone, stays first */
|
||||||
|
.respawn-state {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 0.05rem 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confidence badge — compact */
|
||||||
|
.respawn-banner .detection-confidence {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
padding: 0.02rem 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cycles — compact */
|
||||||
|
.respawn-cycles {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Run timer — compact */
|
||||||
|
.respawn-timer {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tokens — compact, truncate if long */
|
||||||
|
.respawn-tokens {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner icon — smaller on phone */
|
||||||
|
.respawn-indicator {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stop button — 36px touch target, pushed right */
|
||||||
.respawn-banner .btn-icon-only {
|
.respawn-banner .btn-icon-only {
|
||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
@@ -1247,26 +1311,50 @@ html.mobile-init .file-browser-panel {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Action log — horizontal strip below status, border on top instead of left */
|
/* Row2: tighter spacing, smaller text */
|
||||||
.respawn-action-log {
|
.respawn-status-row2 {
|
||||||
border-left: none;
|
font-size: 0.55rem;
|
||||||
border-top: 1px solid rgba(34, 197, 94, 0.2);
|
gap: 0.3rem;
|
||||||
padding-left: 0;
|
min-height: 0;
|
||||||
padding-top: 0.25rem;
|
flex-wrap: wrap;
|
||||||
max-height: 2em;
|
|
||||||
font-size: 0.6rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Countdown timers — tighter on mobile */
|
/* Detection badges — smaller on phone */
|
||||||
|
.respawn-banner .detection-hook,
|
||||||
|
.respawn-banner .detection-ai-check,
|
||||||
|
.respawn-banner .detection-status {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
padding: 0.02rem 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Countdown timers — inline, no progress bars */
|
||||||
.respawn-countdown-timers {
|
.respawn-countdown-timers {
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.respawn-countdown-timer {
|
.respawn-countdown-timer {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
padding: 0.02rem 0.2rem;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide progress bars on phone — values are enough */
|
||||||
|
.respawn-countdown-timer .respawn-timer-bar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action log — single-line strip below status, show latest entry only */
|
||||||
|
.respawn-action-log {
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid rgba(34, 197, 94, 0.15);
|
||||||
|
padding-left: 0;
|
||||||
|
padding-top: 0.15rem;
|
||||||
|
max-height: 1.4em;
|
||||||
font-size: 0.55rem;
|
font-size: 0.55rem;
|
||||||
padding: 0.05rem 0.25rem;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Session Options Modal: Respawn Tab Mobile Optimizations ---- */
|
/* ---- Session Options Modal: Respawn Tab Mobile Optimizations ---- */
|
||||||
@@ -1292,28 +1380,27 @@ html.mobile-init .file-browser-panel {
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Duration preset buttons — single row, all 7 items */
|
/* Duration preset buttons — grid layout, 4 columns for even spacing */
|
||||||
.duration-presets {
|
.duration-presets {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 0.25rem;
|
gap: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.duration-preset-btn {
|
.duration-preset-btn {
|
||||||
min-height: 28px;
|
min-height: 32px;
|
||||||
padding: 0.2rem 0.4rem;
|
padding: 0.2rem 0.25rem;
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom duration — inline with other presets */
|
/* Custom duration — spans full row below */
|
||||||
.duration-custom {
|
.duration-custom {
|
||||||
flex-wrap: wrap;
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
flex: 1 1 100%;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.duration-custom .duration-preset-btn {
|
.duration-custom .duration-preset-btn {
|
||||||
@@ -1331,24 +1418,23 @@ html.mobile-init .file-browser-panel {
|
|||||||
font-size: 16px; /* Prevents iOS zoom */
|
font-size: 16px; /* Prevents iOS zoom */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Preset selector row — compact inline */
|
/* Preset selector row — full-width dropdown, buttons below */
|
||||||
.preset-selector {
|
.preset-selector {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-selector select {
|
.preset-selector select {
|
||||||
width: 100%;
|
grid-column: 1 / -1;
|
||||||
min-height: 28px;
|
min-height: 32px;
|
||||||
font-size: 16px; /* Prevents iOS zoom */
|
font-size: 16px; /* Prevents iOS zoom */
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 0.2rem 0.4rem;
|
padding: 0.2rem 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-selector .btn {
|
.preset-selector .btn {
|
||||||
flex: 1;
|
min-height: 32px;
|
||||||
min-height: 28px;
|
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 0.2rem 0.4rem;
|
padding: 0.2rem 0.4rem;
|
||||||
@@ -1971,7 +2057,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1979,10 +2066,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;
|
||||||
@@ -2021,24 +2113,6 @@ html.mobile-init .file-browser-panel {
|
|||||||
background: #2563eb;
|
background: #2563eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accessory-btn-dismiss {
|
|
||||||
padding: 8px 14px;
|
|
||||||
background: #2a2a2a;
|
|
||||||
border: 1.5px solid rgba(255, 255, 255, 0.25);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #e5e5e5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accessory-btn-dismiss svg {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
stroke-width: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accessory-btn-dismiss:active {
|
|
||||||
background: #3a3a3a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
iOS Safari Specific Fixes
|
iOS Safari Specific Fixes
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|||||||
@@ -324,6 +324,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
envOverrides.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
|
envOverrides.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
|
||||||
}
|
}
|
||||||
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
|
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
|
||||||
|
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
|
||||||
|
const modelOverride = useOpus1m ? 'opus[1m]' : '';
|
||||||
|
|
||||||
// Step 1: Create all sessions in parallel
|
// Step 1: Create all sessions in parallel
|
||||||
this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`);
|
this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`);
|
||||||
@@ -331,7 +333,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
fetch('/api/sessions', {
|
fetch('/api/sessions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ workingDir, name, ...(hasEnvOverrides ? { envOverrides } : {}) })
|
body: JSON.stringify({
|
||||||
|
workingDir, name,
|
||||||
|
...(hasEnvOverrides ? { envOverrides } : {}),
|
||||||
|
...(modelOverride !== undefined ? { modelOverride } : {}),
|
||||||
|
})
|
||||||
}).then(r => r.json())
|
}).then(r => r.json())
|
||||||
);
|
);
|
||||||
const createResults = await Promise.all(createPromises);
|
const createResults = await Promise.all(createPromises);
|
||||||
@@ -913,6 +919,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||||
const settings = this.getCaseSettings(caseName);
|
const settings = this.getCaseSettings(caseName);
|
||||||
document.getElementById('caseAgentTeams').checked = settings.agentTeams;
|
document.getElementById('caseAgentTeams').checked = settings.agentTeams;
|
||||||
|
document.getElementById('caseOpusContext1m').checked = settings.opusContext1m;
|
||||||
popover.classList.remove('hidden');
|
popover.classList.remove('hidden');
|
||||||
|
|
||||||
// Close on outside click (one-shot listener)
|
// Close on outside click (one-shot listener)
|
||||||
@@ -934,7 +941,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const stored = localStorage.getItem('caseSettings_' + caseName);
|
const stored = localStorage.getItem('caseSettings_' + caseName);
|
||||||
if (stored) return JSON.parse(stored);
|
if (stored) return JSON.parse(stored);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return { agentTeams: false };
|
return { agentTeams: false, opusContext1m: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
saveCaseSettings(caseName, settings) {
|
saveCaseSettings(caseName, settings) {
|
||||||
@@ -945,10 +952,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||||
const settings = this.getCaseSettings(caseName);
|
const settings = this.getCaseSettings(caseName);
|
||||||
settings.agentTeams = document.getElementById('caseAgentTeams').checked;
|
settings.agentTeams = document.getElementById('caseAgentTeams').checked;
|
||||||
|
settings.opusContext1m = document.getElementById('caseOpusContext1m').checked;
|
||||||
this.saveCaseSettings(caseName, settings);
|
this.saveCaseSettings(caseName, settings);
|
||||||
// Sync mobile checkbox
|
// Sync mobile checkboxes
|
||||||
const mobileCheckbox = document.getElementById('caseAgentTeamsMobile');
|
const mobileCheckbox = document.getElementById('caseAgentTeamsMobile');
|
||||||
if (mobileCheckbox) mobileCheckbox.checked = settings.agentTeams;
|
if (mobileCheckbox) mobileCheckbox.checked = settings.agentTeams;
|
||||||
|
const mobileOpusCheckbox = document.getElementById('caseOpusContext1mMobile');
|
||||||
|
if (mobileOpusCheckbox) mobileOpusCheckbox.checked = settings.opusContext1m;
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleCaseSettingsMobile() {
|
toggleCaseSettingsMobile() {
|
||||||
@@ -957,6 +967,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||||
const settings = this.getCaseSettings(caseName);
|
const settings = this.getCaseSettings(caseName);
|
||||||
document.getElementById('caseAgentTeamsMobile').checked = settings.agentTeams;
|
document.getElementById('caseAgentTeamsMobile').checked = settings.agentTeams;
|
||||||
|
document.getElementById('caseOpusContext1mMobile').checked = settings.opusContext1m;
|
||||||
popover.classList.remove('hidden');
|
popover.classList.remove('hidden');
|
||||||
|
|
||||||
const closeHandler = (e) => {
|
const closeHandler = (e) => {
|
||||||
@@ -975,10 +986,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||||
const settings = this.getCaseSettings(caseName);
|
const settings = this.getCaseSettings(caseName);
|
||||||
settings.agentTeams = document.getElementById('caseAgentTeamsMobile').checked;
|
settings.agentTeams = document.getElementById('caseAgentTeamsMobile').checked;
|
||||||
|
settings.opusContext1m = document.getElementById('caseOpusContext1mMobile').checked;
|
||||||
this.saveCaseSettings(caseName, settings);
|
this.saveCaseSettings(caseName, settings);
|
||||||
// Sync desktop checkbox
|
// Sync desktop checkboxes
|
||||||
const desktopCheckbox = document.getElementById('caseAgentTeams');
|
const desktopCheckbox = document.getElementById('caseAgentTeams');
|
||||||
if (desktopCheckbox) desktopCheckbox.checked = settings.agentTeams;
|
if (desktopCheckbox) desktopCheckbox.checked = settings.agentTeams;
|
||||||
|
const desktopOpusCheckbox = document.getElementById('caseOpusContext1m');
|
||||||
|
if (desktopOpusCheckbox) desktopOpusCheckbox.checked = settings.opusContext1m;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -1024,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();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1138,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
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -1167,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"/>
|
||||||
@@ -1212,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();
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
};
|
};
|
||||||
// Claude Permissions settings
|
// Claude Permissions settings
|
||||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||||
|
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
|
||||||
// CPU Priority settings
|
// CPU Priority settings
|
||||||
const niceSettings = settings.nice || {};
|
const niceSettings = settings.nice || {};
|
||||||
document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false;
|
document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false;
|
||||||
@@ -1130,6 +1131,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
||||||
// Claude Permissions settings
|
// Claude Permissions settings
|
||||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||||
|
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
||||||
// CPU Priority settings
|
// CPU Priority settings
|
||||||
nice: {
|
nice: {
|
||||||
enabled: document.getElementById('appSettingsNiceEnabled').checked,
|
enabled: document.getElementById('appSettingsNiceEnabled').checked,
|
||||||
@@ -1276,7 +1278,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');
|
||||||
@@ -1315,7 +1317,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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
validatePathWithinBase,
|
validatePathWithinBase,
|
||||||
} from '../route-helpers.js';
|
} from '../route-helpers.js';
|
||||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||||
import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js';
|
import { writeHooksConfig, updateCaseEnvVars, updateCaseModel } from '../../hooks-config.js';
|
||||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
import { imageWatcher } from '../../image-watcher.js';
|
import { imageWatcher } from '../../image-watcher.js';
|
||||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||||
@@ -166,6 +166,11 @@ export function registerSessionRoutes(
|
|||||||
await updateCaseEnvVars(workingDir, body.envOverrides);
|
await updateCaseEnvVars(workingDir, body.envOverrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write model override to .claude/settings.local.json if provided
|
||||||
|
if (body.modelOverride !== undefined) {
|
||||||
|
await updateCaseModel(workingDir, body.modelOverride || null);
|
||||||
|
}
|
||||||
|
|
||||||
// Check OpenCode availability if requested
|
// Check OpenCode availability if requested
|
||||||
if (body.mode === 'opencode') {
|
if (body.mode === 'opencode') {
|
||||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||||||
@@ -181,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,
|
||||||
@@ -761,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,
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ export const CreateSessionSchema = z.object({
|
|||||||
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
||||||
name: z.string().max(100).optional(),
|
name: z.string().max(100).optional(),
|
||||||
envOverrides: safeEnvOverridesSchema,
|
envOverrides: safeEnvOverridesSchema,
|
||||||
|
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
|
||||||
|
modelOverride: z.string().max(50).optional(),
|
||||||
openCodeConfig: OpenCodeConfigSchema,
|
openCodeConfig: OpenCodeConfigSchema,
|
||||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||||
resumeSessionId: z
|
resumeSessionId: z
|
||||||
@@ -429,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;
|
||||||
+14
-12
@@ -17,6 +17,7 @@ export class MockSession extends EventEmitter {
|
|||||||
status: 'idle' | 'working' = 'idle';
|
status: 'idle' | 'working' = 'idle';
|
||||||
pid: number = 12345;
|
pid: number = 12345;
|
||||||
isWorking: boolean = false;
|
isWorking: boolean = false;
|
||||||
|
private _activeChildProcesses: { pid: number; command: string }[] = [];
|
||||||
ralphTracker: null = null;
|
ralphTracker: null = null;
|
||||||
writeBuffer: string[] = [];
|
writeBuffer: string[] = [];
|
||||||
terminalBuffer: string = '';
|
terminalBuffer: string = '';
|
||||||
@@ -52,9 +53,7 @@ export class MockSession extends EventEmitter {
|
|||||||
|
|
||||||
/** Check if a specific command was written */
|
/** Check if a specific command was written */
|
||||||
hasWritten(pattern: string | RegExp): boolean {
|
hasWritten(pattern: string | RegExp): boolean {
|
||||||
return this.writeBuffer.some((data) =>
|
return this.writeBuffer.some((data) => (typeof pattern === 'string' ? data.includes(pattern) : pattern.test(data)));
|
||||||
typeof pattern === 'string' ? data.includes(pattern) : pattern.test(data),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Terminal Output Simulation ==========
|
// ========== Terminal Output Simulation ==========
|
||||||
@@ -113,10 +112,7 @@ export class MockSession extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
simulatePlanModePrompt(): void {
|
simulatePlanModePrompt(): void {
|
||||||
this.simulateTerminalOutput(
|
this.simulateTerminalOutput(
|
||||||
'Would you like to proceed with this plan?\n' +
|
'Would you like to proceed with this plan?\n' + '\u276f 1. Yes\n' + ' 2. No\n' + ' 3. Type your own\n'
|
||||||
'\u276f 1. Yes\n' +
|
|
||||||
' 2. No\n' +
|
|
||||||
' 3. Type your own\n',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,11 +127,7 @@ export class MockSession extends EventEmitter {
|
|||||||
/** Simulate token count display */
|
/** Simulate token count display */
|
||||||
simulateTokenCount(tokens: number | string): void {
|
simulateTokenCount(tokens: number | string): void {
|
||||||
const formatted =
|
const formatted =
|
||||||
typeof tokens === 'number'
|
typeof tokens === 'number' ? (tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens)) : tokens;
|
||||||
? tokens >= 1000
|
|
||||||
? `${(tokens / 1000).toFixed(1)}k`
|
|
||||||
: String(tokens)
|
|
||||||
: tokens;
|
|
||||||
this.simulateTerminalOutput(`${formatted} tokens used`);
|
this.simulateTerminalOutput(`${formatted} tokens used`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +159,16 @@ export class MockSession extends EventEmitter {
|
|||||||
return this._muxName;
|
return this._muxName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check for active child processes (mock returns configurable list) */
|
||||||
|
getActiveChildProcesses(): { pid: number; command: string }[] {
|
||||||
|
return this._activeChildProcesses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set active child processes for testing */
|
||||||
|
setActiveChildProcesses(processes: { pid: number; command: string }[]): void {
|
||||||
|
this._activeChildProcesses = processes;
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Route-test conveniences ==========
|
// ========== Route-test conveniences ==========
|
||||||
|
|
||||||
/** Session display name */
|
/** Session display name */
|
||||||
|
|||||||
Reference in new issue
Block a user