From 64b8ea30b28103299e3b6de9dcf74ad500c1136a Mon Sep 17 00:00:00 2001 From: arkon Date: Tue, 31 Mar 2026 03:10:22 +0200 Subject: [PATCH 01/13] chore: version packages --- CHANGELOG.md | 6 ++++++ CLAUDE.md | 2 +- package.json | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4a7d04..c93986fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # aicodeman +## 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 diff --git a/CLAUDE.md b/CLAUDE.md index 373b5189..aec5ae0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` 5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web` -**Version**: 0.5.6 (must match `package.json`) +**Version**: 0.5.7 (must match `package.json`) ## Project Overview diff --git a/package.json b/package.json index e7f048bc..a26a16b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "0.5.6", + "version": "0.5.7", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", From 2cba393ae57d905b2cbb5b2bd53b2f6b90167500 Mon Sep 17 00:00:00 2001 From: arkon Date: Tue, 31 Mar 2026 19:34:39 +0200 Subject: [PATCH 02/13] fix: installer fails on macOS when piped via curl | bash When running `curl | bash`, stdin is the pipe, not the terminal. Homebrew and sudo need TTY access to prompt for the password. Redirect /dev/tty as stdin for these subprocesses. Co-Authored-By: Claude Opus 4.6 --- install.sh | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/install.sh b/install.sh index 2d7d6b8b..bb9103dc 100755 --- a/install.sh +++ b/install.sh @@ -353,8 +353,15 @@ ensure_sudo() { die "sudo is required but not installed. Please install packages manually or run as root." fi # Validate sudo access - if ! sudo -v 2>/dev/null; then - die "Failed to obtain sudo privileges." + # When piped (curl | bash), stdin is the pipe — redirect from /dev/tty so sudo can prompt + 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 } @@ -372,7 +379,12 @@ ensure_homebrew() { fi 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 if [[ -f /opt/homebrew/bin/brew ]]; then From 53b473708f549359df9f13c8dc0b31c5a4a28d39 Mon Sep 17 00:00:00 2001 From: arkon Date: Wed, 1 Apr 2026 08:51:37 +0200 Subject: [PATCH 03/13] =?UTF-8?q?fix:=20macOS=20support=20=E2=80=94=20HTML?= =?UTF-8?q?=20cache,=20launchd=20service,=20trust=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for macOS deployments: 1. HTML cache bug: @fastify/static with preCompressed serves .html.br/.html.gz files, so path.endsWith('.html') missed them — HTML got 1-year immutable cache headers instead of no-cache, causing stale pages after deploys. 2. Installer launchd support: macOS now gets proper LaunchAgent setup (like systemd on Linux). Removes competing LaunchDaemons to prevent duplicate services fighting over the port. Update/uninstall also handle launchd. 3. Trust dialog auto-accept: Claude CLI 2.x shows a workspace trust prompt on first launch per directory. Sessions detect "trust this folder" in PTY output and auto-send Enter, preventing sessions from hanging on startup. Co-Authored-By: Claude Opus 4.6 --- install.sh | 151 ++++++++++++++++++++++++++++++++++++++++------ src/session.ts | 11 ++++ src/web/server.ts | 3 +- 3 files changed, 145 insertions(+), 20 deletions(-) diff --git a/install.sh b/install.sh index bb9103dc..4ced9a39 100755 --- a/install.sh +++ b/install.sh @@ -7,7 +7,7 @@ # Environment variables: # CODEMAN_NONINTERACTIVE=1 - Skip all prompts (for CI/automation) # 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_REPO_URL - Custom git repository URL (default: upstream Codeman) # CODEMAN_BRANCH - Git branch to install (default: master) @@ -799,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 + + + + + Label + $plist_label + ProgramArguments + + $node_path + $INSTALL_DIR/dist/index.js + web + + EnvironmentVariables + + PATH + $svc_path + HOME + $HOME + LANG + en_US.UTF-8 + + WorkingDirectory + $HOME + RunAtLoad + + KeepAlive + + ThrottleInterval + 10 + StandardOutPath + /tmp/codeman.log + StandardErrorPath + /tmp/codeman.log + + +EOF + + launchctl load "$agent_plist" 2>/dev/null || true + + success "LaunchAgent installed and started" +} + setup_systemd_service() { local service_dir="$HOME/.config/systemd/user" local service_file="$service_dir/codeman-web.service" @@ -1151,17 +1226,25 @@ main() { echo "" 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 - has_systemd=true + has_service=true + service_type="systemd" + elif [[ "$os" == "macos" ]] && [[ "$SKIP_SYSTEMD" != "1" ]]; then + has_service=true + service_type="launchd" 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 "" 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 "" @@ -1178,7 +1261,7 @@ main() { done fi 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 "" echo -e " ${CYAN}1)${NC} Run now in this terminal" @@ -1204,12 +1287,16 @@ main() { echo "" - # Handle systemd setup + # Handle service setup 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 - if check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then + # Offer tunnel service if cloudflared is available (Linux only — systemd tunnel service) + if [[ "$service_type" == "systemd" ]] && check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then echo "" if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then setup_tunnel_service @@ -1224,10 +1311,16 @@ main() { echo "" echo -e " ${BOLD}Manage the service:${NC}" echo "" - 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" + if [[ "$service_type" == "launchd" ]]; then + echo -e " ${CYAN}launchctl unload ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Stop" + echo -e " ${CYAN}launchctl load ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Start" + 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 "" fi @@ -1301,11 +1394,17 @@ update() { success "Updated to $(node -e "console.log(require('./package.json').version)")" echo "" - # Auto-restart systemd service if it's running, otherwise tell the user - if systemctl --user is-active codeman-web.service &>/dev/null; then + # Auto-restart service if running, otherwise tell the user + 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..." systemctl --user restart codeman-web.service 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 echo -e " ${DIM}Restart codeman web to use the new version:${NC}" echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}" @@ -1318,9 +1417,9 @@ uninstall() { info "Uninstalling Codeman..." echo "" - # Stop and remove systemd services + # Stop and remove systemd services (Linux) 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..." systemctl --user stop "${svc}.service" fi @@ -1336,6 +1435,20 @@ uninstall() { done 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 local symlink_dir="$HOME/.local/bin" if [[ -L "$symlink_dir/codeman" ]]; then diff --git a/src/session.ts b/src/session.ts index a60557cb..918714b6 100644 --- a/src/session.ts +++ b/src/session.ts @@ -274,6 +274,7 @@ export class Session extends EventEmitter { private _lastPromptTime: number = 0; private activityTimeout: NodeJS.Timeout | null = null; private _awaitingIdleConfirmation: boolean = false; // Prevents timeout reset during idle detection + private _trustDialogAccepted: boolean = false; // Prevents repeated trust dialog auto-accept private _taskTracker: TaskTracker; // Token tracking for auto-clear @@ -1118,6 +1119,16 @@ export class Session extends EventEmitter { 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) === // Detect if Claude is working or at prompt // The prompt line contains "❯" when waiting for input diff --git a/src/web/server.ts b/src/web/server.ts index 5395d04b..f918dcbd 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -544,7 +544,8 @@ export class WebServer extends EventEmitter { cacheControl: false, preCompressed: true, 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'); } else { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); From 28a6247c2746d49205ef1a1c5916cae3edefecc4 Mon Sep 17 00:00:00 2001 From: Teigen Date: Thu, 2 Apr 2026 21:35:00 +0800 Subject: [PATCH 04/13] fix: auto-attach PTY to surviving tmux sessions on server restart Previously, restoreMuxSessions() only created Session objects without attaching PTY processes. Sessions stayed at pid=null until the client manually selected them, causing terminals to appear "closed" after deploy. Now the server calls startInteractive() for each recovered session during startup, so all sessions resume capturing output immediately. The frontend auto-attach condition is also relaxed from (pid===null && status==='idle') to (pid===null && !_ended) as a safety net for edge cases. --- src/web/public/app.js | 5 +++-- src/web/server.ts | 29 +++++++++++++++++++++-------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 35b10fb5..8c9a297f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2105,8 +2105,9 @@ class CodemanApp { // Track working directory for path normalization in Project Insights this.currentSessionWorkingDir = session?.workingDir || null; - if (session && session.pid === null && session.status === 'idle') { - // This is a restored session - attach to the existing screen/shell + if (session && session.pid === null && !session._ended) { + // Session has no PTY attached — either restored after server restart + // or detached for some other reason. Re-attach regardless of status. try { const endpoint = session.mode === 'shell' ? `/api/sessions/${sessionId}/shell` diff --git a/src/web/server.ts b/src/web/server.ts index 5395d04b..e93df334 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1766,15 +1766,28 @@ export class WebServer extends EventEmitter { this.sessions.set(session.id, session); await this.setupSessionListeners(session); - this.persistSessionState(session); - // Mark it as restored (not started yet - user needs to attach) - getLifecycleLog().log({ - event: 'recovered', - sessionId: session.id, - name: session.name, - }); - console.log(`[Server] Restored session ${session.id} from mux ${muxSession.muxName}`); + // Auto-attach PTY to the surviving tmux session immediately. + // This ensures ALL sessions resume capturing output right away, + // not just the one the client happens to select first. + try { + await session.startInteractive(); + getLifecycleLog().log({ + 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); } } From 24a6f1cac83dca9cacd3ace2f11c63b4df7b2d4b Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 3 Apr 2026 03:51:00 +0200 Subject: [PATCH 05/13] chore: remove accidentally committed build artifact and dev-specific script Remove dist/state-store.js (compiled build artifact that should not be tracked) and scripts/claudeman-launchd-wrapper.sh (developer-specific launchd wrapper with hardcoded paths) that were included in #55. Co-Authored-By: Claude Opus 4.6 --- dist/state-store.js | 822 --------------------------- scripts/claudeman-launchd-wrapper.sh | 33 -- 2 files changed, 855 deletions(-) delete mode 100644 dist/state-store.js delete mode 100755 scripts/claudeman-launchd-wrapper.sh diff --git a/dist/state-store.js b/dist/state-store.js deleted file mode 100644 index 60f2fb04..00000000 --- a/dist/state-store.js +++ /dev/null @@ -1,822 +0,0 @@ -/** - * @fileoverview Persistent JSON state storage for Codeman. - * - * Persists application state with debounced writes (500ms) to prevent excessive disk I/O. - * State is split into two files: - * - `~/.codeman/state.json` — main app state (sessions, tasks, config, global stats) - * - `~/.codeman/state-inner.json` — Ralph loop state per session (changes rapidly) - * - * Key exports: - * - `StateStore` class — singleton store with circuit breaker for save failures - * - `getStore(filePath?)` — factory/singleton accessor - * - * Key methods: `getState()`, `getSessions()`, `setSession()`, `getConfig()`, - * `setConfig()`, `getGlobalStats()`, `getAggregateStats()`, `getTokenStats()`, - * `getDailyStats()`, `getRalphState()`, `setRalphState()`, `save()`, `saveNow()` - * - * Auto-migrates legacy `~/.claudeman/` → `~/.codeman/` on first load. - * - * @dependencies types (AppState, RalphSessionState, GlobalStats, TokenStats), - * utils (Debouncer, MAX_SESSION_TOKENS) - * @consumedby session-manager, ralph-loop, web/server, respawn-controller, - * hooks-config, and most subsystems - * - * @module state-store - */ -import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, copyFileSync } from 'node:fs'; -import { writeFile, rename, unlink, copyFile, access } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { createInitialState, createInitialRalphSessionState, createInitialGlobalStats, } from './types.js'; -import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js'; -/** Debounce delay for batching state writes (ms) */ -const SAVE_DEBOUNCE_MS = 500; -/** - * Persistent JSON state storage with debounced writes. - * - * State is automatically loaded on construction and saved with 500ms - * debouncing to batch rapid updates into single disk writes. - * - * @example - * ```typescript - * const store = new StateStore(); - * - * // Read state - * const sessions = store.getState().sessions; - * - * // Modify and save - * store.getState().sessions[id] = sessionState; - * store.save(); // Debounced - won't write immediately - * - * // Force immediate write - * store.saveNow(); - * ``` - */ -/** Maximum consecutive save failures before circuit breaker opens */ -const MAX_CONSECUTIVE_FAILURES = 3; -export class StateStore { - state; - filePath; - saveDeb = new Debouncer(SAVE_DEBOUNCE_MS); - dirty = false; - dirtySessions = new Set(); - cachedSessionJsons = new Map(); - // Inner state storage (separate from main state to reduce write frequency) - ralphStates = new Map(); - ralphStatePath; - ralphStateSaveDeb = new Debouncer(SAVE_DEBOUNCE_MS); - ralphStateDirty = false; - // Circuit breaker for save failures (prevents hammering disk on persistent errors) - consecutiveSaveFailures = 0; - circuitBreakerOpen = false; - // Guard against concurrent saveNowAsync() calls (debounce can race with in-flight write) - _saveInFlight = null; - constructor(filePath) { - // Migrate legacy data directory (~/.claudeman → ~/.codeman) - if (!filePath) { - const legacyDir = join(homedir(), '.claudeman'); - const newDir = join(homedir(), '.codeman'); - if (existsSync(legacyDir) && !existsSync(newDir)) { - console.log(`[state-store] Migrating data directory: ${legacyDir} → ${newDir}`); - renameSync(legacyDir, newDir); - } - const legacyCasesDir = join(homedir(), 'claudeman-cases'); - const newCasesDir = join(homedir(), 'codeman-cases'); - if (existsSync(legacyCasesDir) && !existsSync(newCasesDir)) { - console.log(`[state-store] Migrating cases directory: ${legacyCasesDir} → ${newCasesDir}`); - renameSync(legacyCasesDir, newCasesDir); - } - } - this.filePath = filePath || join(homedir(), '.codeman', 'state.json'); - this.ralphStatePath = this.filePath.replace('.json', '-inner.json'); - this.state = this.load(); - this.state.config.stateFilePath = this.filePath; - // Pre-populate session cache for loaded state - for (const [id, session] of Object.entries(this.state.sessions)) { - this.cachedSessionJsons.set(id, JSON.stringify(session)); - } - this.loadRalphStates(); - } - _mergeWithInitialState(parsed) { - const initial = createInitialState(); - return { - ...initial, - ...parsed, - sessions: { ...parsed.sessions }, - tasks: { ...parsed.tasks }, - ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop }, - config: { ...initial.config, ...parsed.config }, - }; - } - _resetCircuitBreaker() { - this.consecutiveSaveFailures = 0; - if (this.circuitBreakerOpen) { - console.log('[StateStore] Circuit breaker CLOSED - save succeeded'); - this.circuitBreakerOpen = false; - } - } - ensureDir() { - const dir = dirname(this.filePath); - if (!existsSync(dir)) { - // Use restrictive permissions (0o700) - owner only can read/write/traverse - // State files may contain sensitive session data - mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - } - load() { - // Try main file first, then .bak fallback - for (const path of [this.filePath, this.filePath + '.bak']) { - try { - if (existsSync(path)) { - const data = readFileSync(path, 'utf-8'); - const parsed = JSON.parse(data); - const result = this._mergeWithInitialState(parsed); - if (path !== this.filePath) { - console.warn(`[StateStore] Recovered state from backup: ${path}`); - } - return result; - } - } - catch (err) { - console.error(`Failed to load state from ${path}:`, err); - } - } - return createInitialState(); - } - /** - * Schedules a debounced save. - * Multiple calls within 500ms are batched into a single disk write. - * Uses async I/O to avoid blocking the event loop. - */ - save() { - this.dirty = true; - if (this.saveDeb.isPending) - return; // Already scheduled - this.saveDeb.schedule(() => { - this.saveNowAsync().catch((err) => { - console.error('[StateStore] Async save failed:', err); - }); - }); - } - /** - * Async version of saveNow — used by the debounced save() path. - * Uses non-blocking fs.promises to avoid blocking the event loop during - * the debounced write cycle. For synchronous shutdown flush, use saveNow(). - * - * Guards against concurrent execution: if a save is already in flight, - * waits for it to complete then re-checks dirty flag before starting another. - */ - async saveNowAsync() { - if (this._saveInFlight) { - await this._saveInFlight; - // After waiting, re-check if still dirty (the previous save may have handled it) - if (!this.dirty) - return; - } - this._saveInFlight = this._doSaveAsync(); - try { - await this._saveInFlight; - } - finally { - this._saveInFlight = null; - } - } - /** - * Assemble JSON string with incremental per-session caching. - * Only dirty sessions are re-serialized; clean sessions use cached JSON fragments. - */ - assembleStateJson() { - this.updateDirtySessionCache(); - // Build sessions object from cached fragments - const sessionParts = []; - for (const [id, session] of Object.entries(this.state.sessions)) { - let json = this.cachedSessionJsons.get(id); - if (!json) { - // Session not in cache (loaded from disk or set via direct state mutation) - json = JSON.stringify(session); - this.cachedSessionJsons.set(id, json); - } - sessionParts.push(`${JSON.stringify(id)}:${json}`); - } - this.pruneStaleCacheEntries(); - return this.buildPartialJson(sessionParts); - } - updateDirtySessionCache() { - // Re-serialize dirty sessions and update cache - for (const id of this.dirtySessions) { - const session = this.state.sessions[id]; - if (session) { - this.cachedSessionJsons.set(id, JSON.stringify(session)); - } - else { - this.cachedSessionJsons.delete(id); - } - } - this.dirtySessions.clear(); - } - pruneStaleCacheEntries() { - // Prune stale cache entries (sessions removed via direct state mutation) - if (this.cachedSessionJsons.size > Object.keys(this.state.sessions).length) { - for (const cachedId of this.cachedSessionJsons.keys()) { - if (!(cachedId in this.state.sessions)) { - this.cachedSessionJsons.delete(cachedId); - } - } - } - } - buildPartialJson(sessionParts) { - // Build final JSON: sessions from cache, everything else re-serialized (tiny) - const sessionsJson = `{${sessionParts.join(',')}}`; - // Serialize non-session fields individually (they're small) - const parts = [ - `"sessions":${sessionsJson}`, - `"tasks":${JSON.stringify(this.state.tasks)}`, - `"ralphLoop":${JSON.stringify(this.state.ralphLoop)}`, - `"config":${JSON.stringify(this.state.config)}`, - ]; - // Optional fields - if (this.state.globalStats) { - parts.push(`"globalStats":${JSON.stringify(this.state.globalStats)}`); - } - if (this.state.tokenStats) { - parts.push(`"tokenStats":${JSON.stringify(this.state.tokenStats)}`); - } - return `{${parts.join(',')}}`; - } - serializeState() { - try { - return this.assembleStateJson(); - } - catch (assembleErr) { - // Fallback to full serialization if incremental assembly fails - console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr); - this.cachedSessionJsons.clear(); - this.dirtySessions.clear(); - try { - return JSON.stringify(this.state); - } - catch (err) { - console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err); - this.consecutiveSaveFailures++; - if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) { - console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly'); - this.circuitBreakerOpen = true; - } - return null; - } - } - } - async _doSaveAsync() { - this.saveDeb.cancel(); - if (!this.dirty) { - return; - } - // Circuit breaker: stop attempting writes after too many failures - if (this.circuitBreakerOpen) { - console.warn('[StateStore] Circuit breaker open - skipping save (too many consecutive failures)'); - return; - } - this.ensureDir(); - const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; - const backupPath = this.filePath + '.bak'; - // Step 1: Serialize state (validates it's JSON-safe) - const json = this.serializeState(); - if (json === null) - return; - // Clear dirty flag BEFORE async I/O so mutations during write re-set it. - // The state snapshot is already captured in `json` above. - this.dirty = false; - // Step 2: Create backup via file copy (async, no read+parse+write) - try { - await access(this.filePath); - await copyFile(this.filePath, backupPath); - } - catch { - // Backup failed or file doesn't exist yet - continue with write - } - // Step 3: Atomic write: write to temp file, then rename (async) - try { - await writeFile(tempPath, json, 'utf-8'); - await rename(tempPath, this.filePath); - this._resetCircuitBreaker(); - } - catch (err) { - console.error('[StateStore] Failed to write state file:', err); - // Re-mark dirty so the data is retried on the next save cycle - this.dirty = true; - this.consecutiveSaveFailures++; - // Try to clean up temp file on error - try { - await unlink(tempPath); - } - catch { - // Temp file may not exist - } - // Check circuit breaker threshold - if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) { - console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly'); - this.circuitBreakerOpen = true; - } - } - } - /** - * Synchronous immediate write to disk using atomic write pattern. - * Used by flushAll() during shutdown when async is not appropriate. - * Prefer saveNowAsync() for normal operation. - */ - saveNow() { - this.saveDeb.cancel(); - if (!this.dirty) { - return; - } - if (this.circuitBreakerOpen) { - console.warn('[StateStore] Circuit breaker open - skipping save (too many consecutive failures)'); - return; - } - this.ensureDir(); - const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; - const backupPath = this.filePath + '.bak'; - const json = this.serializeState(); - if (json === null) - return; - // Backup via atomic copy (avoids reading entire file into memory) - try { - if (existsSync(this.filePath)) { - copyFileSync(this.filePath, backupPath); - } - } - catch { - // Backup failed - continue with write - } - try { - writeFileSync(tempPath, json, 'utf-8'); - renameSync(tempPath, this.filePath); - // Clear dirty flag only AFTER successful write - this.dirty = false; - this._resetCircuitBreaker(); - } - catch (err) { - console.error('[StateStore] Failed to write state file:', err); - this.consecutiveSaveFailures++; - try { - if (existsSync(tempPath)) - unlinkSync(tempPath); - } - catch { - /* ignore */ - } - if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) { - console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly'); - this.circuitBreakerOpen = true; - } - } - } - /** - * Attempt to recover state from backup file. - * Call this if main state file is corrupt. - */ - recoverFromBackup() { - const backupPath = this.filePath + '.bak'; - try { - if (existsSync(backupPath)) { - const backupContent = readFileSync(backupPath, 'utf-8'); - const parsed = JSON.parse(backupContent); - this.state = this._mergeWithInitialState(parsed); - console.log('[StateStore] Successfully recovered state from backup'); - // Reset circuit breaker after successful recovery - this.circuitBreakerOpen = false; - this.consecutiveSaveFailures = 0; - return true; - } - } - catch (err) { - console.error('[StateStore] Failed to recover from backup:', err); - } - return false; - } - /** - * Reset the circuit breaker (for manual intervention). - */ - resetCircuitBreaker() { - this.circuitBreakerOpen = false; - this.consecutiveSaveFailures = 0; - console.log('[StateStore] Circuit breaker manually reset'); - } - /** Flushes any pending main state save. Call before shutdown. */ - flush() { - this.saveNow(); - } - /** Returns the full application state object. */ - getState() { - return this.state; - } - /** Returns all session states keyed by session ID. */ - getSessions() { - return this.state.sessions; - } - /** Returns a session state by ID, or null if not found. */ - getSession(id) { - return this.state.sessions[id] ?? null; - } - /** Sets a session state and triggers a debounced save. */ - setSession(id, session) { - this.state.sessions[id] = session; - this.dirtySessions.add(id); - this.save(); - } - /** Removes a session state and triggers a debounced save. */ - removeSession(id) { - delete this.state.sessions[id]; - this.cachedSessionJsons.delete(id); - this.dirtySessions.delete(id); - this.save(); - } - /** - * Cleans up stale sessions from state that don't have corresponding active sessions. - * @param activeSessionIds - Set of currently active session IDs - * @returns Number of sessions cleaned up - */ - cleanupStaleSessions(activeSessionIds) { - const allSessionIds = Object.keys(this.state.sessions); - const cleaned = []; - for (const sessionId of allSessionIds) { - if (!activeSessionIds.has(sessionId)) { - const name = this.state.sessions[sessionId]?.name; - cleaned.push({ id: sessionId, name }); - delete this.state.sessions[sessionId]; - this.cachedSessionJsons.delete(sessionId); - this.dirtySessions.delete(sessionId); - // Also clean up Ralph state for this session - this.ralphStates.delete(sessionId); - } - } - if (cleaned.length > 0) { - console.log(`[StateStore] Cleaned up ${cleaned.length} stale session(s) from state`); - this.save(); - } - return { count: cleaned.length, cleaned }; - } - /** Returns all task states keyed by task ID. */ - getTasks() { - return this.state.tasks; - } - /** Returns a task state by ID, or null if not found. */ - getTask(id) { - return this.state.tasks[id] ?? null; - } - /** Sets a task state and triggers a debounced save. */ - setTask(id, task) { - this.state.tasks[id] = task; - this.save(); - } - /** Removes a task state and triggers a debounced save. */ - removeTask(id) { - delete this.state.tasks[id]; - this.save(); - } - /** Returns the Ralph Loop state. */ - getRalphLoopState() { - return this.state.ralphLoop; - } - /** Updates Ralph Loop state (partial merge) and triggers a debounced save. */ - setRalphLoopState(ralphLoop) { - this.state.ralphLoop = { ...this.state.ralphLoop, ...ralphLoop }; - this.save(); - } - // ========== Orchestrator Loop State Methods ========== - /** Returns the orchestrator loop state, or null if never initialized. */ - getOrchestratorState() { - return this.state.orchestrator ?? null; - } - /** Updates orchestrator loop state (partial merge) and triggers a debounced save. */ - setOrchestratorState(orchestrator) { - if (this.state.orchestrator) { - this.state.orchestrator = { ...this.state.orchestrator, ...orchestrator }; - } - else { - // First initialization — caller must provide full state - this.state.orchestrator = orchestrator; - } - this.save(); - } - /** Clears orchestrator state and triggers a debounced save. */ - clearOrchestratorState() { - this.state.orchestrator = undefined; - this.save(); - } - /** Returns the application configuration. */ - getConfig() { - return this.state.config; - } - /** Updates configuration (partial merge) and triggers a debounced save. */ - setConfig(config) { - this.state.config = { ...this.state.config, ...config }; - this.save(); - } - /** Resets all state to initial values and saves immediately. */ - reset() { - this.state = createInitialState(); - this.state.config.stateFilePath = this.filePath; - this.ralphStates.clear(); - this.cachedSessionJsons.clear(); - this.dirtySessions.clear(); - this.saveNow(); // Immediate save for reset operations - this.saveRalphStatesNow(); - } - // ========== Global Stats Methods ========== - /** Returns global stats, creating initial stats if needed. */ - getGlobalStats() { - if (!this.state.globalStats) { - this.state.globalStats = createInitialGlobalStats(); - } - return this.state.globalStats; - } - /** - * Adds tokens and cost to global stats. - * Call when a session is deleted to preserve its usage in lifetime stats. - */ - addToGlobalStats(inputTokens, outputTokens, cost) { - // Sanity check: reject absurdly large values - if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) { - console.warn(`[StateStore] Rejected absurd global stats: input=${inputTokens}, output=${outputTokens}`); - return; - } - // Reject negative values - if (inputTokens < 0 || outputTokens < 0 || cost < 0) { - console.warn(`[StateStore] Rejected negative global stats: input=${inputTokens}, output=${outputTokens}, cost=${cost}`); - return; - } - const stats = this.getGlobalStats(); - stats.totalInputTokens += inputTokens; - stats.totalOutputTokens += outputTokens; - stats.totalCost += cost; - stats.lastUpdatedAt = Date.now(); - this.save(); - } - /** Increments the total sessions created counter. */ - incrementSessionsCreated() { - const stats = this.getGlobalStats(); - stats.totalSessionsCreated += 1; - stats.lastUpdatedAt = Date.now(); - this.save(); - } - /** - * Returns aggregate stats combining global (deleted sessions) + active sessions. - * @param activeSessions Map of active session states - */ - getAggregateStats(activeSessions) { - const global = this.getGlobalStats(); - let activeInput = 0; - let activeOutput = 0; - let activeCost = 0; - let activeCount = 0; - for (const session of Object.values(activeSessions)) { - activeInput += session.inputTokens ?? 0; - activeOutput += session.outputTokens ?? 0; - activeCost += session.totalCost ?? 0; - activeCount++; - } - return { - totalInputTokens: global.totalInputTokens + activeInput, - totalOutputTokens: global.totalOutputTokens + activeOutput, - totalCost: global.totalCost + activeCost, - totalSessionsCreated: global.totalSessionsCreated, - activeSessionsCount: activeCount, - }; - } - // ========== Token Stats Methods (Daily Tracking) ========== - /** Maximum days to keep in daily history */ - static MAX_DAILY_HISTORY = 30; - /** - * Get or initialize token stats from state. - */ - getTokenStats() { - if (!this.state.tokenStats) { - this.state.tokenStats = { - daily: [], - lastUpdated: Date.now(), - }; - } - return this.state.tokenStats; - } - /** - * Get today's date string in YYYY-MM-DD format. - */ - getTodayDateString() { - const now = new Date(); - return now.toISOString().split('T')[0]; - } - /** - * Calculate estimated cost from tokens using Claude Opus pricing. - * Input: $15/M tokens, Output: $75/M tokens - */ - calculateEstimatedCost(inputTokens, outputTokens) { - const inputCost = (inputTokens / 1000000) * 15; - const outputCost = (outputTokens / 1000000) * 75; - return inputCost + outputCost; - } - // Track unique sessions per day for accurate session count - dailySessionIds = new Set(); - dailySessionDate = ''; - /** - * Record token usage for today. - * Accumulates tokens to today's entry, creating it if needed. - * @param inputTokens Input tokens to add - * @param outputTokens Output tokens to add - * @param sessionId Optional session ID for unique session counting - */ - recordDailyUsage(inputTokens, outputTokens, sessionId) { - if (inputTokens <= 0 && outputTokens <= 0) - return; - // Sanity check: reject absurdly large values (max 1M tokens per recording) - // Claude's context window is ~200k, so 1M per recording is already very generous - const MAX_TOKENS_PER_RECORDING = 1_000_000; - if (inputTokens > MAX_TOKENS_PER_RECORDING || outputTokens > MAX_TOKENS_PER_RECORDING) { - console.warn(`[StateStore] Rejected absurd token values: input=${inputTokens}, output=${outputTokens}`); - return; - } - const stats = this.getTokenStats(); - const today = this.getTodayDateString(); - // Reset daily session tracking on date change - if (this.dailySessionDate !== today) { - this.dailySessionIds.clear(); - this.dailySessionDate = today; - } - // Find or create today's entry - let todayEntry = stats.daily.find((e) => e.date === today); - if (!todayEntry) { - todayEntry = { - date: today, - inputTokens: 0, - outputTokens: 0, - estimatedCost: 0, - sessions: 0, - }; - stats.daily.unshift(todayEntry); // Add to front (most recent first) - } - // Accumulate tokens - todayEntry.inputTokens += inputTokens; - todayEntry.outputTokens += outputTokens; - todayEntry.estimatedCost = this.calculateEstimatedCost(todayEntry.inputTokens, todayEntry.outputTokens); - // Only increment session count for unique sessions - if (sessionId && !this.dailySessionIds.has(sessionId)) { - this.dailySessionIds.add(sessionId); - todayEntry.sessions = this.dailySessionIds.size; - } - // Prune old entries (keep last 30 days) - if (stats.daily.length > StateStore.MAX_DAILY_HISTORY) { - stats.daily = stats.daily.slice(0, StateStore.MAX_DAILY_HISTORY); - } - stats.lastUpdated = Date.now(); - this.save(); - } - /** - * Get daily stats for display. - * @param days Number of days to return (default: 30) - * @returns Array of daily entries, most recent first - */ - getDailyStats(days = 30) { - const stats = this.getTokenStats(); - return stats.daily.slice(0, days); - } - // ========== Inner State Methods (Ralph Loop tracking) ========== - loadRalphStates() { - try { - if (existsSync(this.ralphStatePath)) { - const data = readFileSync(this.ralphStatePath, 'utf-8'); - const parsed = JSON.parse(data); - for (const [sessionId, state] of Object.entries(parsed)) { - this.ralphStates.set(sessionId, state); - } - } - } - catch (err) { - console.error('Failed to load inner states:', err); - } - } - // Debounced save for inner states - saveRalphStates() { - this.ralphStateDirty = true; - if (this.ralphStateSaveDeb.isPending) - return; // Already scheduled - this.ralphStateSaveDeb.schedule(() => { - this.saveRalphStatesNow(); - }); - } - /** - * Immediate save for inner states using atomic write pattern. - * Writes to temp file first, then renames to prevent corruption on crash. - */ - saveRalphStatesNow() { - this.ralphStateSaveDeb.cancel(); - if (!this.ralphStateDirty) { - return; - } - // Clear dirty flag only on success to enable retry on failure - this.ensureDir(); - const data = Object.fromEntries(this.ralphStates); - // Atomic write: write to temp file, then rename (atomic on POSIX) - const tempPath = this.ralphStatePath + '.tmp'; - let json; - try { - json = JSON.stringify(data); - } - catch (err) { - console.error('[StateStore] Failed to serialize Ralph state (circular reference or invalid data):', err); - // Keep dirty flag true for retry - don't throw, let caller continue - return; - } - try { - writeFileSync(tempPath, json, 'utf-8'); - renameSync(tempPath, this.ralphStatePath); - // Success - clear dirty flag - this.ralphStateDirty = false; - } - catch (err) { - console.error('[StateStore] Failed to write Ralph state file:', err); - // Keep dirty flag true for retry on next save - // Try to clean up temp file on error - try { - if (existsSync(tempPath)) { - unlinkSync(tempPath); - } - } - catch (cleanupErr) { - console.warn('[StateStore] Failed to cleanup temp file during Ralph state save error:', cleanupErr); - } - // Don't throw - let caller continue, retry on next save - } - } - /** Returns inner state for a session, or null if not found. */ - getRalphState(sessionId) { - return this.ralphStates.get(sessionId) ?? null; - } - /** Sets inner state for a session and triggers a debounced save. */ - setRalphState(sessionId, state) { - this.ralphStates.set(sessionId, state); - this.saveRalphStates(); - } - /** - * Updates inner state for a session (partial merge). - * Creates initial state if none exists. - * @returns The updated inner state. - */ - updateRalphState(sessionId, updates) { - let state = this.ralphStates.get(sessionId); - if (!state) { - state = createInitialRalphSessionState(sessionId); - } - state = { ...state, ...updates, lastUpdated: Date.now() }; - this.ralphStates.set(sessionId, state); - this.saveRalphStates(); - return state; - } - /** Removes inner state for a session and triggers a debounced save. */ - removeRalphState(sessionId) { - if (this.ralphStates.has(sessionId)) { - this.ralphStates.delete(sessionId); - this.saveRalphStates(); - } - } - /** Returns a copy of all inner states as a Map. */ - getAllRalphStates() { - return new Map(this.ralphStates); - } - /** Flushes all pending saves (main and inner state). Call before shutdown. */ - flushAll() { - // Save both states, catching errors to ensure both are attempted - let mainError = null; - let ralphError = null; - try { - this.saveNow(); - } - catch (err) { - mainError = err; - console.error('[StateStore] Error flushing main state:', err); - } - try { - this.saveRalphStatesNow(); - } - catch (err) { - ralphError = err; - console.error('[StateStore] Error flushing Ralph state:', err); - } - // Log summary if any errors occurred - if (mainError || ralphError) { - console.warn('[StateStore] flushAll completed with errors - some state may not be persisted'); - } - } -} -// Singleton instance -let storeInstance = null; -/** - * Gets or creates the singleton StateStore instance. - * @param filePath Optional custom file path (only used on first call). - */ -export function getStore(filePath) { - if (!storeInstance) { - storeInstance = new StateStore(filePath); - } - return storeInstance; -} -//# sourceMappingURL=state-store.js.map \ No newline at end of file diff --git a/scripts/claudeman-launchd-wrapper.sh b/scripts/claudeman-launchd-wrapper.sh deleted file mode 100755 index 3c07a3bb..00000000 --- a/scripts/claudeman-launchd-wrapper.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash -# Claudeman launchd wrapper -# -# 根因: Node 25 被 launchd 直接拉起时 V8 bootstrapper 概率性死锁 -# (进程存在、端口不监听、日志空白、sample 显示卡在 LoadEnvironment) -# 手动 nohup 同样环境则正常。通过 bash wrapper + exec 绕过此问题。 -# -# 额外加固: -# - 启动前清理占 3000 端口的野进程 -# - 写启动日志到 stderr(被 launchd 重定向到 StandardErrorPath) - -set -euo pipefail - -PORT=3000 -CLAUDEMAN_DIR="/Users/teigen/Documents/Workspace/AI_project/Claudeman" - -export HOME=/Users/teigen -export PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin - -echo "[wrapper] $(date '+%Y-%m-%d %H:%M:%S') starting claudeman web" >&2 - -# 清理占端口的野进程(非本进程树的残留 node) -STALE_PIDS=$(/usr/sbin/lsof -nP -iTCP:${PORT} -sTCP:LISTEN -t 2>/dev/null || true) -if [[ -n "$STALE_PIDS" ]]; then - echo "[wrapper] clearing stale processes on port ${PORT}: ${STALE_PIDS}" >&2 - for pid in $STALE_PIDS; do - kill "$pid" 2>/dev/null || true - done - sleep 2 -fi - -cd "$CLAUDEMAN_DIR" -exec /opt/homebrew/bin/node dist/index.js web --https -p "$PORT" From bd9797b68cbf2b5e5ac6a08609965920e17958b3 Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 3 Apr 2026 03:58:29 +0200 Subject: [PATCH 06/13] fix: sanitize case names from filesystem to prevent XSS in inline handlers Filter readdir and linked-case names through /^[a-zA-Z0-9_-]+$/ before returning them from GET /api/cases. Prevents XSS via maliciously-named directories reaching frontend inline onclick handlers where escapeHtml is insufficient (HTML-decoded back to quotes before JS execution). Also fix misleading "Drag or use arrows" hint (no drag-and-drop exists). Co-Authored-By: Claude Opus 4.6 --- src/web/public/index.html | 2 +- src/web/routes/case-routes.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/web/public/index.html b/src/web/public/index.html index b8599ba1..4dc12f27 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1393,7 +1393,7 @@
- Drag or use arrows to reorder. Changes are saved automatically. + Use arrows to reorder. Changes are saved automatically.
diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 6b825164..b3508ddc 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -19,6 +19,7 @@ import { SseEvent } from '../sse-events.js'; import type { EventPort, ConfigPort } from '../ports/index.js'; 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. */ async function readLinkedCases(): Promise> { @@ -46,7 +47,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config try { const entries = await fs.readdir(CASES_DIR, { withFileTypes: true }); for (const e of entries) { - if (e.isDirectory()) { + if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) { cases.push({ name: 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 existingNames = new Set(cases.map((c) => c.name)); 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({ name, path, From 89d787a949d096f0de9fcbc28036a79418296d91 Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 3 Apr 2026 04:01:08 +0200 Subject: [PATCH 07/13] chore: version packages --- CHANGELOG.md | 20 ++++++++++++++++++++ CLAUDE.md | 2 +- package.json | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c93986fc..344051c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # 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 diff --git a/CLAUDE.md b/CLAUDE.md index aec5ae0c..0d515e1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` 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.7 (must match `package.json`) +**Version**: 0.5.8 (must match `package.json`) ## Project Overview diff --git a/package.json b/package.json index a26a16b9..b3690301 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "0.5.7", + "version": "0.5.8", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", From 0a594b61bd2f05ec112d2332889df2883d37edd1 Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 3 Apr 2026 04:17:33 +0200 Subject: [PATCH 08/13] chore: version packages --- CHANGELOG.md | 12 +++++ CLAUDE.md | 2 +- package.json | 2 +- src/web/public/app.js | 3 ++ src/web/public/index.html | 11 +++++ src/web/public/keyboard-accessory.js | 67 ++++++++++++++++++++++------ src/web/public/mobile.css | 63 +++++++++++++++++++++----- src/web/public/settings-ui.js | 9 +++- 8 files changed, 139 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 344051c1..d9667044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # aicodeman +## 0.5.9 + +### Patch Changes + +- Mobile keyboard accessory bar: add configurable "Extended Keyboard Bar" setting (Settings > Display > Input) that toggles between simple mode (up/down arrows, /init, /clear, /compact, paste, dismiss) and extended mode (adds left/right arrows, Tab, Shift+Tab, Ctrl+O, Alt+Enter, Esc). Default is simple mode. Setting is device-specific (not synced to server). + + Restyle dismiss button: muted steel-blue tone, fills remaining bar space via flex, larger tap target. Arrow buttons now blue. + + Fix paste overlay visibility on mobile: dialog repositioned to top of screen (15vh from top) so the virtual keyboard doesn't cover it. Textarea enlarged for better usability. + + (Also includes all v0.5.8 changes: case reorder/delete, XSS sanitization, auto-attach PTY on restart, mobile keyboard buttons, macOS installer fixes, terminal flicker fix, state store collision fix.) + ## 0.5.8 ### Patch Changes diff --git a/CLAUDE.md b/CLAUDE.md index 0d515e1c..a582ac52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` 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.8 (must match `package.json`) +**Version**: 0.5.9 (must match `package.json`) ## Project Overview diff --git a/package.json b/package.json index b3690301..a29f4cec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "0.5.8", + "version": "0.5.9", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/web/public/app.js b/src/web/public/app.js index 8c9a297f..dac3df42 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -523,6 +523,9 @@ class CodemanApp { SwipeHandler.init(); VoiceInput.init(); KeyboardAccessoryBar.init(); + // Apply keyboard bar mode from settings + const _kbSettings = this.loadAppSettingsFromStorage(); + if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended'); this.applyHeaderVisibilitySettings(); this.applyTabWrapSettings(); this.applyMonitorVisibility(); diff --git a/src/web/public/index.html b/src/web/public/index.html index 4dc12f27..42f95d3f 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -892,6 +892,17 @@
+
+
+ Extended Keyboard Bar + Extra keys: Tab, Esc, arrows, Ctrl+O +
+ +
+
Header Displays
diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 9ae2bee7..e58aebba 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -4,7 +4,7 @@ * Defines two exports: * * - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual - * keyboard on mobile: Esc, arrow up/down, Tab, Shift+Tab, Ctrl+O, /init, /clear, /compact, paste, and dismiss. + * keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, and dismiss. * Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state). * Commands are sent as text + Enter separately for Ink compatibility. * Only initializes on touch devices (MobileDetection.isTouchDevice guard). @@ -33,16 +33,37 @@ */ const KeyboardAccessoryBar = { element: null, + _mode: 'simple', // 'simple' or 'extended' - /** Create and inject the accessory bar */ - init() { - // Only on mobile - if (!MobileDetection.isTouchDevice()) return; + /** HTML for simple mode: arrows, commands, paste, dismiss */ + _simpleButtons: ` + + + + + + + `, - // Create accessory bar element - this.element = document.createElement('div'); - this.element.className = 'keyboard-accessory-bar'; - this.element.innerHTML = ` + /** HTML for extended mode: all keys including arrows, Tab, Esc, etc. */ + _extendedButtons: `