diff --git a/.gitignore b/.gitignore index 832800f1..01ebf95f 100644 --- a/.gitignore +++ b/.gitignore @@ -48,7 +48,12 @@ Thumbs.db # Generated output out/ screenshots-echo-diag/ -tools/remotion/out/ +scripts/remotion/out/ + +# Artifacts that should not be tracked +test-results/ +tmp/ +public # Claude Code plan tracking plan.json diff --git a/.prettierignore b/.prettierignore index 7ac646ba..c3cf1aa4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,4 +6,4 @@ src/web/public/app.js src/web/public/styles.css src/web/public/mobile.css src/web/public/index.html -tools/ +scripts/remotion/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4a7d04..e81377b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # aicodeman +## 0.5.10 + +### Patch Changes + +- fix: allow bracket characters in model validation regex so models like opus[1m] (1M context window) are accepted instead of silently dropped. Quote the model flag value in tmux spawn commands to prevent bash glob expansion of bracket patterns. + + docs: update macOS launchd instructions to use `launchctl bootstrap` instead of deprecated `load`. Clean up README install and service sections. + +## 0.5.9 + +### Patch Changes + +- Mobile keyboard accessory bar: add configurable "Extended Keyboard Bar" setting (Settings > Display > Input) that toggles between simple mode (up/down arrows, /init, /clear, /compact, paste, dismiss) and extended mode (adds left/right arrows, Tab, Shift+Tab, Ctrl+O, Alt+Enter, Esc). Default is simple mode. Setting is device-specific (not synced to server). + + Restyle dismiss button: muted steel-blue tone, fills remaining bar space via flex, larger tap target. Arrow buttons now blue. + + Fix paste overlay visibility on mobile: dialog repositioned to top of screen (15vh from top) so the virtual keyboard doesn't cover it. Textarea enlarged for better usability. + + (Also includes all v0.5.8 changes: case reorder/delete, XSS sanitization, auto-attach PTY on restart, mobile keyboard buttons, macOS installer fixes, terminal flicker fix, state store collision fix.) + +## 0.5.8 + +### Patch Changes + +- Case management: add Manage tab with reorder (up/down arrows) and delete for cases; linked cases are unlinked (folder preserved), CASES_DIR cases are permanently deleted. New endpoints: DELETE /api/cases/:name, PUT /api/cases/order. SSE events: case:deleted, case:order-changed. + + Security: sanitize case names from filesystem with /^[a-zA-Z0-9_-]+$/ regex before returning from GET /api/cases to prevent XSS via maliciously-named directories reaching frontend inline onclick handlers. + + Auto-attach PTY: server now calls startInteractive() for recovered tmux sessions during startup so all sessions resume capturing output immediately after deploy, instead of waiting for client selection. Frontend auto-attach condition relaxed from (pid===null && status==='idle') to (pid===null && !\_ended). + + Mobile keyboard accessory: add Shift+Tab, Tab, Esc, Alt+Enter, Left/Right arrow, and Ctrl+O buttons. + + Terminal: fix flicker regression by moving viewport clear inside dimension guard. + + State store: fix temp file collisions on concurrent writes. + + macOS: fix installer failures when piped via curl | bash, add HTML cache support, launchd service template, and trust dialog handling. + + Housekeeping: remove accidentally committed dist/state-store.js build artifact. + +## 0.5.7 + +### Patch Changes + +- feat: support "Default (CLI default)" option for model selection. Adds a new empty-value option to the model dropdown that defers to the CLI's own default model instead of forcing a specific model. Ensures empty defaultModel values are treated as undefined when passed to session creation and Ralph loop start, preventing empty strings from being sent as model flags. + ## 0.5.6 ### Patch Changes diff --git a/CLAUDE.md b/CLAUDE.md index 373b5189..5cd778b3 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.10 (must match `package.json`) ## Project Overview @@ -80,7 +80,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, `format:check` on push to master/main and on PRs (Node 22). Tests excluded (they spawn tmux). -**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `tools/**`, `remotion/**`. +**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`. ## Common Gotchas @@ -136,7 +136,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`. -**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `agent-teams/`. +**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`. **Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`. @@ -204,7 +204,7 @@ npx vitest run -t "pattern" # By name (SAFE) **Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests. -**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject()` in `test/routes/`. **Mobile tests**: Playwright suite in `mobile-test/` (135 device profiles). +**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject()` in `test/routes/`. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles). ## Screenshots @@ -226,7 +226,7 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina ## References -Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`, `orchestrator-loop-architecture.md`, `browser-testing-guide.md`. Agent Teams: `agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`. +Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`, `orchestrator-loop-architecture.md`, `browser-testing-guide.md`. Agent Teams: `docs/agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`. ## Scripts diff --git a/README.md b/README.md index e70fc1f4..dd370b3a 100644 --- a/README.md +++ b/README.md @@ -30,24 +30,6 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | b This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it. -**Install from a fork or specific branch:** -```bash -curl -fsSL https://raw.githubusercontent.com//Codeman//install.sh | \ - CODEMAN_REPO_URL=https://github.com//Codeman.git \ - CODEMAN_BRANCH= bash -``` - -The installer supports these environment variables: - -| Variable | Default | Description | -|----------|---------|-------------| -| `CODEMAN_REPO_URL` | upstream Codeman | Custom git repository URL | -| `CODEMAN_BRANCH` | `master` | Git branch to install | -| `CODEMAN_INSTALL_DIR` | `~/.codeman/app` | Custom install directory | -| `CODEMAN_SKIP_SYSTEMD` | `0` | Skip systemd service setup prompt | -| `CODEMAN_NODE_VERSION` | `22` | Node.js major version to install | -| `CODEMAN_NONINTERACTIVE` | `0` | Skip all prompts (for CI/automation) | - You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai) (or both). After install: ```bash @@ -60,12 +42,53 @@ codeman web **Linux (systemd):** ```bash -mkdir -p ~/.config/systemd/user && printf '[Unit]\nDescription=Codeman Web Server\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=%s %s/dist/index.js web\nRestart=always\nRestartSec=10\n\n[Install]\nWantedBy=default.target\n' "$(which node)" "$HOME/.codeman/app" > ~/.config/systemd/user/codeman-web.service && systemctl --user daemon-reload && systemctl --user enable --now codeman-web && loginctl enable-linger $USER +mkdir -p ~/.config/systemd/user +cat > ~/.config/systemd/user/codeman-web.service << EOF +[Unit] +Description=Codeman Web Server +After=network.target + +[Service] +Type=simple +ExecStart=$(which node) $HOME/.codeman/app/dist/index.js web +Restart=always +RestartSec=10 + +[Install] +WantedBy=default.target +EOF +systemctl --user daemon-reload +systemctl --user enable --now codeman-web +loginctl enable-linger $USER ``` **macOS (launchd):** ```bash -mkdir -p ~/Library/LaunchAgents && printf '\n\nLabelcom.codeman.webProgramArguments%s%s/dist/index.jswebRunAtLoadKeepAliveStandardOutPath/tmp/codeman.logStandardErrorPath/tmp/codeman.log\n' "$(which node)" "$HOME/.codeman/app" > ~/Library/LaunchAgents/com.codeman.web.plist && launchctl load ~/Library/LaunchAgents/com.codeman.web.plist +mkdir -p ~/Library/LaunchAgents +cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF + + + + + Label + com.codeman.web + ProgramArguments + + $(which node) + $HOME/.codeman/app/dist/index.js + web + + RunAtLoad + KeepAlive + StandardOutPath + /tmp/codeman.log + StandardErrorPath + /tmp/codeman.log + + +EOF +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist ``` diff --git a/eslint.config.js b/config/eslint.config.js similarity index 94% rename from eslint.config.js rename to config/eslint.config.js index 4d594b25..05ffba98 100644 --- a/eslint.config.js +++ b/config/eslint.config.js @@ -22,8 +22,7 @@ export default tseslint.config( 'src/web/public/vendor/**', 'src/web/public/app.js', 'scripts/**/*.mjs', - 'tools/**', - 'remotion/**', + 'scripts/remotion/**', ], } ); diff --git a/vitest.config.ts b/config/vitest.config.ts similarity index 87% rename from vitest.config.ts rename to config/vitest.config.ts index 874b7f75..48ecb9c2 100644 --- a/vitest.config.ts +++ b/config/vitest.config.ts @@ -1,7 +1,11 @@ +import { resolve } from 'node:path'; import { defineConfig } from 'vitest/config'; +const root = resolve(import.meta.dirname, '..'); + export default defineConfig({ test: { + root, globals: true, environment: 'node', include: ['test/**/*.test.ts'], 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/agent-teams/README.md b/docs/agent-teams/README.md similarity index 100% rename from agent-teams/README.md rename to docs/agent-teams/README.md diff --git a/agent-teams/design.md b/docs/agent-teams/design.md similarity index 100% rename from agent-teams/design.md rename to docs/agent-teams/design.md diff --git a/agent-teams/experiment-log.md b/docs/agent-teams/experiment-log.md similarity index 100% rename from agent-teams/experiment-log.md rename to docs/agent-teams/experiment-log.md diff --git a/install.sh b/install.sh index 2d7d6b8b..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) @@ -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 @@ -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 + + + + + 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" @@ -1139,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 "" @@ -1166,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" @@ -1192,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 @@ -1212,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 @@ -1289,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}" @@ -1306,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 @@ -1324,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/package.json b/package.json index e7f048bc..42bfe289 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "0.5.6", + "version": "0.5.10", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", @@ -15,12 +15,12 @@ "dev": "tsx src/index.ts web", "web": "node dist/index.js web", "clean": "rm -rf dist", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", + "test": "vitest run --config config/vitest.config.ts", + "test:watch": "vitest --config config/vitest.config.ts", + "test:coverage": "vitest run --config config/vitest.config.ts --coverage", "typecheck": "tsc --noEmit", - "lint": "eslint 'src/**/*.ts'", - "lint:fix": "eslint 'src/**/*.ts' --fix", + "lint": "eslint --config config/eslint.config.js 'src/**/*.ts'", + "lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix", "format": "prettier --write 'src/**/*.ts'", "format:check": "prettier --check 'src/**/*.ts'", "capture:subagents": "node scripts/capture-subagent-screenshots.mjs", diff --git a/public b/public deleted file mode 120000 index b9e407f6..00000000 --- a/public +++ /dev/null @@ -1 +0,0 @@ -tools/remotion/public \ No newline at end of file diff --git a/scripts/capture-readme-screenshots.mjs b/scripts/capture-readme-screenshots.mjs index f4b34f49..f2bb5ffb 100644 --- a/scripts/capture-readme-screenshots.mjs +++ b/scripts/capture-readme-screenshots.mjs @@ -428,7 +428,7 @@ const SUBAGENT_ACTIVITY = { 'agent-002': [ { type: 'tool', tool: 'Glob', input: { pattern: 'test/**/*.test.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' }, { type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' }, - { type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/vitest.config.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' }, + { type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/config/vitest.config.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' }, { type: 'message', role: 'assistant', text: 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...', timestamp: new Date().toISOString(), agentId: 'agent-002' }, ], }; diff --git a/scripts/capture-video-screenshots.mjs b/scripts/capture-video-screenshots.mjs index 037d27b9..172e332c 100644 --- a/scripts/capture-video-screenshots.mjs +++ b/scripts/capture-video-screenshots.mjs @@ -9,7 +9,7 @@ * * Usage: node scripts/capture-video-screenshots.mjs * Port: 3198 (static file server) - * Output: remotion/public/ (6 PNGs) + * Output: scripts/scripts/remotion/public/ (6 PNGs) */ import { chromium } from 'playwright'; @@ -21,7 +21,7 @@ import { fileURLToPath } from 'url'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = join(__dirname, '..'); const PUBLIC_DIR = join(PROJECT_ROOT, 'src', 'web', 'public'); -const OUTPUT_DIR = join(PROJECT_ROOT, 'remotion', 'public'); +const OUTPUT_DIR = join(PROJECT_ROOT, 'scripts', 'remotion', 'public'); const PORT = 3198; const DESKTOP_VIEWPORT = { width: 1920, height: 1080 }; @@ -516,7 +516,7 @@ async function captureDesktopWelcome(browser) { path: join(OUTPUT_DIR, 'desktop-welcome.png'), fullPage: false, }); - console.log(' Saved: remotion/public/desktop-welcome.png'); + console.log(' Saved: scripts/remotion/public/desktop-welcome.png'); } finally { await context.close(); } @@ -545,7 +545,7 @@ async function captureDesktopClaude(browser) { path: join(OUTPUT_DIR, 'desktop-claude.png'), fullPage: false, }); - console.log(' Saved: remotion/public/desktop-claude.png'); + console.log(' Saved: scripts/remotion/public/desktop-claude.png'); } finally { await context.close(); } @@ -575,7 +575,7 @@ async function captureDesktopBothClaude(browser) { path: join(OUTPUT_DIR, 'desktop-both-claude.png'), fullPage: false, }); - console.log(' Saved: remotion/public/desktop-both-claude.png'); + console.log(' Saved: scripts/remotion/public/desktop-both-claude.png'); } finally { await context.close(); } @@ -605,7 +605,7 @@ async function captureDesktopBothOpencode(browser) { path: join(OUTPUT_DIR, 'desktop-both-opencode.png'), fullPage: false, }); - console.log(' Saved: remotion/public/desktop-both-opencode.png'); + console.log(' Saved: scripts/remotion/public/desktop-both-opencode.png'); } finally { await context.close(); } @@ -634,7 +634,7 @@ async function captureMobileClaude(browser) { path: join(OUTPUT_DIR, 'mobile-claude.png'), fullPage: false, }); - console.log(' Saved: remotion/public/mobile-claude.png'); + console.log(' Saved: scripts/remotion/public/mobile-claude.png'); } finally { await context.close(); } @@ -663,7 +663,7 @@ async function captureMobileOpencode(browser) { path: join(OUTPUT_DIR, 'mobile-opencode.png'), fullPage: false, }); - console.log(' Saved: remotion/public/mobile-opencode.png'); + console.log(' Saved: scripts/remotion/public/mobile-opencode.png'); } finally { await context.close(); } @@ -706,12 +706,12 @@ async function main() { console.log('All 6 screenshots captured!'); console.log('='.repeat(60)); console.log('\nOutput files:'); - console.log(' remotion/public/desktop-welcome.png'); - console.log(' remotion/public/desktop-claude.png'); - console.log(' remotion/public/desktop-both-claude.png'); - console.log(' remotion/public/desktop-both-opencode.png'); - console.log(' remotion/public/mobile-claude.png'); - console.log(' remotion/public/mobile-opencode.png'); + console.log(' scripts/remotion/public/desktop-welcome.png'); + console.log(' scripts/remotion/public/desktop-claude.png'); + console.log(' scripts/remotion/public/desktop-both-claude.png'); + console.log(' scripts/remotion/public/desktop-both-opencode.png'); + console.log(' scripts/remotion/public/mobile-claude.png'); + console.log(' scripts/remotion/public/mobile-opencode.png'); } catch (err) { console.error('\nFatal error:', err.message); console.error(err.stack); 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" diff --git a/tools/remotion/Root.tsx b/scripts/remotion/Root.tsx similarity index 100% rename from tools/remotion/Root.tsx rename to scripts/remotion/Root.tsx diff --git a/tools/remotion/components/ClickCursor.tsx b/scripts/remotion/components/ClickCursor.tsx similarity index 100% rename from tools/remotion/components/ClickCursor.tsx rename to scripts/remotion/components/ClickCursor.tsx diff --git a/tools/remotion/components/IOSKeyboard.tsx b/scripts/remotion/components/IOSKeyboard.tsx similarity index 100% rename from tools/remotion/components/IOSKeyboard.tsx rename to scripts/remotion/components/IOSKeyboard.tsx diff --git a/tools/remotion/components/IPhone17ProFrame.tsx b/scripts/remotion/components/IPhone17ProFrame.tsx similarity index 100% rename from tools/remotion/components/IPhone17ProFrame.tsx rename to scripts/remotion/components/IPhone17ProFrame.tsx diff --git a/tools/remotion/components/PhoneFrame.tsx b/scripts/remotion/components/PhoneFrame.tsx similarity index 100% rename from tools/remotion/components/PhoneFrame.tsx rename to scripts/remotion/components/PhoneFrame.tsx diff --git a/tools/remotion/components/TerminalScreen.tsx b/scripts/remotion/components/TerminalScreen.tsx similarity index 100% rename from tools/remotion/components/TerminalScreen.tsx rename to scripts/remotion/components/TerminalScreen.tsx diff --git a/tools/remotion/components/TitleCard.tsx b/scripts/remotion/components/TitleCard.tsx similarity index 100% rename from tools/remotion/components/TitleCard.tsx rename to scripts/remotion/components/TitleCard.tsx diff --git a/tools/remotion/compositions/CodemanDemo.tsx b/scripts/remotion/compositions/CodemanDemo.tsx similarity index 100% rename from tools/remotion/compositions/CodemanDemo.tsx rename to scripts/remotion/compositions/CodemanDemo.tsx diff --git a/tools/remotion/compositions/ZerolagDemo.tsx b/scripts/remotion/compositions/ZerolagDemo.tsx similarity index 100% rename from tools/remotion/compositions/ZerolagDemo.tsx rename to scripts/remotion/compositions/ZerolagDemo.tsx diff --git a/tools/remotion/index.ts b/scripts/remotion/index.ts similarity index 100% rename from tools/remotion/index.ts rename to scripts/remotion/index.ts diff --git a/tools/remotion/lib/theme.ts b/scripts/remotion/lib/theme.ts similarity index 100% rename from tools/remotion/lib/theme.ts rename to scripts/remotion/lib/theme.ts diff --git a/tools/remotion/public/desktop-both-claude.png b/scripts/remotion/public/desktop-both-claude.png similarity index 100% rename from tools/remotion/public/desktop-both-claude.png rename to scripts/remotion/public/desktop-both-claude.png diff --git a/tools/remotion/public/desktop-both-opencode.png b/scripts/remotion/public/desktop-both-opencode.png similarity index 100% rename from tools/remotion/public/desktop-both-opencode.png rename to scripts/remotion/public/desktop-both-opencode.png diff --git a/tools/remotion/public/desktop-claude.png b/scripts/remotion/public/desktop-claude.png similarity index 100% rename from tools/remotion/public/desktop-claude.png rename to scripts/remotion/public/desktop-claude.png diff --git a/tools/remotion/public/desktop-welcome.png b/scripts/remotion/public/desktop-welcome.png similarity index 100% rename from tools/remotion/public/desktop-welcome.png rename to scripts/remotion/public/desktop-welcome.png diff --git a/tools/remotion/public/mobile-claude.png b/scripts/remotion/public/mobile-claude.png similarity index 100% rename from tools/remotion/public/mobile-claude.png rename to scripts/remotion/public/mobile-claude.png diff --git a/tools/remotion/public/mobile-opencode.png b/scripts/remotion/public/mobile-opencode.png similarity index 100% rename from tools/remotion/public/mobile-opencode.png rename to scripts/remotion/public/mobile-opencode.png 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/tmux-manager.ts b/src/tmux-manager.ts index e4b2aab9..69542bfe 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -185,8 +185,8 @@ function buildSpawnCommand(options: { }): string { if (options.mode === 'claude') { // Validate model to prevent command injection - const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined; - const modelFlag = safeModel ? ` --model ${safeModel}` : ''; + const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined; + const modelFlag = safeModel ? ` --model "${safeModel}"` : ''; // Use --resume to restore a previous conversation, otherwise --session-id for new sessions. // Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.), // fall back to a new session with --session-id so the pane doesn't die. diff --git a/src/web/public/app.js b/src/web/public/app.js index 35b10fb5..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(); @@ -2105,8 +2108,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/public/index.html b/src/web/public/index.html index b8599ba1..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
@@ -1393,7 +1404,7 @@
- Drag or use arrows to reorder. Changes are saved automatically. + Use arrows to reorder. Changes are saved automatically.
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: `