Merge remote-tracking branch 'origin/master' into dev

This commit is contained in:
Teigen
2026-04-04 08:07:15 +08:00
96 changed files with 459 additions and 989 deletions
+6 -1
View File
@@ -48,7 +48,12 @@ Thumbs.db
# Generated output # Generated output
out/ out/
screenshots-echo-diag/ screenshots-echo-diag/
tools/remotion/out/ scripts/remotion/out/
# Artifacts that should not be tracked
test-results/
tmp/
public
# Claude Code plan tracking # Claude Code plan tracking
plan.json plan.json
+1 -1
View File
@@ -6,4 +6,4 @@ src/web/public/app.js
src/web/public/styles.css src/web/public/styles.css
src/web/public/mobile.css src/web/public/mobile.css
src/web/public/index.html src/web/public/index.html
tools/ scripts/remotion/
+46
View File
@@ -1,5 +1,51 @@
# aicodeman # aicodeman
## 0.5.10
### Patch Changes
- fix: allow bracket characters in model validation regex so models like opus[1m] (1M context window) are accepted instead of silently dropped. Quote the model flag value in tmux spawn commands to prevent bash glob expansion of bracket patterns.
docs: update macOS launchd instructions to use `launchctl bootstrap` instead of deprecated `load`. Clean up README install and service sections.
## 0.5.9
### Patch Changes
- Mobile keyboard accessory bar: add configurable "Extended Keyboard Bar" setting (Settings > Display > Input) that toggles between simple mode (up/down arrows, /init, /clear, /compact, paste, dismiss) and extended mode (adds left/right arrows, Tab, Shift+Tab, Ctrl+O, Alt+Enter, Esc). Default is simple mode. Setting is device-specific (not synced to server).
Restyle dismiss button: muted steel-blue tone, fills remaining bar space via flex, larger tap target. Arrow buttons now blue.
Fix paste overlay visibility on mobile: dialog repositioned to top of screen (15vh from top) so the virtual keyboard doesn't cover it. Textarea enlarged for better usability.
(Also includes all v0.5.8 changes: case reorder/delete, XSS sanitization, auto-attach PTY on restart, mobile keyboard buttons, macOS installer fixes, terminal flicker fix, state store collision fix.)
## 0.5.8
### Patch Changes
- Case management: add Manage tab with reorder (up/down arrows) and delete for cases; linked cases are unlinked (folder preserved), CASES_DIR cases are permanently deleted. New endpoints: DELETE /api/cases/:name, PUT /api/cases/order. SSE events: case:deleted, case:order-changed.
Security: sanitize case names from filesystem with /^[a-zA-Z0-9_-]+$/ regex before returning from GET /api/cases to prevent XSS via maliciously-named directories reaching frontend inline onclick handlers.
Auto-attach PTY: server now calls startInteractive() for recovered tmux sessions during startup so all sessions resume capturing output immediately after deploy, instead of waiting for client selection. Frontend auto-attach condition relaxed from (pid===null && status==='idle') to (pid===null && !\_ended).
Mobile keyboard accessory: add Shift+Tab, Tab, Esc, Alt+Enter, Left/Right arrow, and Ctrl+O buttons.
Terminal: fix flicker regression by moving viewport clear inside dimension guard.
State store: fix temp file collisions on concurrent writes.
macOS: fix installer failures when piped via curl | bash, add HTML cache support, launchd service template, and trust dialog handling.
Housekeeping: remove accidentally committed dist/state-store.js build artifact.
## 0.5.7
### Patch Changes
- feat: support "Default (CLI default)" option for model selection. Adds a new empty-value option to the model dropdown that defers to the CLI's own default model instead of forcing a specific model. Ensures empty defaultModel values are treated as undefined when passed to session creation and Ralph loop start, preventing empty strings from being sent as model flags.
## 0.5.6 ## 0.5.6
### Patch Changes ### Patch Changes
+5 -5
View File
@@ -52,7 +52,7 @@ When user says "COM":
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json` 4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web` 5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
**Version**: 0.5.6 (must match `package.json`) **Version**: 0.5.10 (must match `package.json`)
## Project Overview ## Project Overview
@@ -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). **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 ## 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`. **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`. **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. **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 ## Screenshots
@@ -226,7 +226,7 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina
## References ## 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 ## Scripts
+43 -20
View File
@@ -30,24 +30,6 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | b
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it. This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it.
**Install from a fork or specific branch:**
```bash
curl -fsSL https://raw.githubusercontent.com/<user>/Codeman/<branch>/install.sh | \
CODEMAN_REPO_URL=https://github.com/<user>/Codeman.git \
CODEMAN_BRANCH=<branch> bash
```
The installer supports these environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `CODEMAN_REPO_URL` | upstream Codeman | Custom git repository URL |
| `CODEMAN_BRANCH` | `master` | Git branch to install |
| `CODEMAN_INSTALL_DIR` | `~/.codeman/app` | Custom install directory |
| `CODEMAN_SKIP_SYSTEMD` | `0` | Skip systemd service setup prompt |
| `CODEMAN_NODE_VERSION` | `22` | Node.js major version to install |
| `CODEMAN_NONINTERACTIVE` | `0` | Skip all prompts (for CI/automation) |
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai) (or both). After install: You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai) (or both). After install:
```bash ```bash
@@ -60,12 +42,53 @@ codeman web
**Linux (systemd):** **Linux (systemd):**
```bash ```bash
mkdir -p ~/.config/systemd/user && printf '[Unit]\nDescription=Codeman Web Server\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=%s %s/dist/index.js web\nRestart=always\nRestartSec=10\n\n[Install]\nWantedBy=default.target\n' "$(which node)" "$HOME/.codeman/app" > ~/.config/systemd/user/codeman-web.service && systemctl --user daemon-reload && systemctl --user enable --now codeman-web && loginctl enable-linger $USER mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/codeman-web.service << EOF
[Unit]
Description=Codeman Web Server
After=network.target
[Service]
Type=simple
ExecStart=$(which node) $HOME/.codeman/app/dist/index.js web
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now codeman-web
loginctl enable-linger $USER
``` ```
**macOS (launchd):** **macOS (launchd):**
```bash ```bash
mkdir -p ~/Library/LaunchAgents && printf '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>com.codeman.web</string><key>ProgramArguments</key><array><string>%s</string><string>%s/dist/index.js</string><string>web</string></array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>StandardOutPath</key><string>/tmp/codeman.log</string><key>StandardErrorPath</key><string>/tmp/codeman.log</string></dict></plist>\n' "$(which node)" "$HOME/.codeman/app" > ~/Library/LaunchAgents/com.codeman.web.plist && launchctl load ~/Library/LaunchAgents/com.codeman.web.plist mkdir -p ~/Library/LaunchAgents
cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.codeman.web</string>
<key>ProgramArguments</key>
<array>
<string>$(which node)</string>
<string>$HOME/.codeman/app/dist/index.js</string>
<string>web</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key>
<string>/tmp/codeman.log</string>
<key>StandardErrorPath</key>
<string>/tmp/codeman.log</string>
</dict>
</plist>
EOF
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
``` ```
</details> </details>
+1 -2
View File
@@ -22,8 +22,7 @@ export default tseslint.config(
'src/web/public/vendor/**', 'src/web/public/vendor/**',
'src/web/public/app.js', 'src/web/public/app.js',
'scripts/**/*.mjs', 'scripts/**/*.mjs',
'tools/**', 'scripts/remotion/**',
'remotion/**',
], ],
} }
); );
@@ -1,7 +1,11 @@
import { resolve } from 'node:path';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
const root = resolve(import.meta.dirname, '..');
export default defineConfig({ export default defineConfig({
test: { test: {
root,
globals: true, globals: true,
environment: 'node', environment: 'node',
include: ['test/**/*.test.ts'], include: ['test/**/*.test.ts'],
-822
View File
@@ -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
+147 -22
View File
@@ -7,7 +7,7 @@
# Environment variables: # Environment variables:
# CODEMAN_NONINTERACTIVE=1 - Skip all prompts (for CI/automation) # CODEMAN_NONINTERACTIVE=1 - Skip all prompts (for CI/automation)
# CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app) # CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app)
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd service setup prompt # CODEMAN_SKIP_SYSTEMD=1 - Skip systemd/launchd service setup prompt
# CODEMAN_NODE_VERSION - Node.js major version to install (default: 22) # CODEMAN_NODE_VERSION - Node.js major version to install (default: 22)
# CODEMAN_REPO_URL - Custom git repository URL (default: upstream Codeman) # CODEMAN_REPO_URL - Custom git repository URL (default: upstream Codeman)
# CODEMAN_BRANCH - Git branch to install (default: master) # CODEMAN_BRANCH - Git branch to install (default: master)
@@ -353,8 +353,15 @@ ensure_sudo() {
die "sudo is required but not installed. Please install packages manually or run as root." die "sudo is required but not installed. Please install packages manually or run as root."
fi fi
# Validate sudo access # Validate sudo access
if ! sudo -v 2>/dev/null; then # When piped (curl | bash), stdin is the pipe — redirect from /dev/tty so sudo can prompt
die "Failed to obtain sudo privileges." if [[ -e /dev/tty ]]; then
if ! sudo -v 2>/dev/null < /dev/tty; then
die "Failed to obtain sudo privileges."
fi
else
if ! sudo -v 2>/dev/null; then
die "Failed to obtain sudo privileges. Try running the script directly instead of piping."
fi
fi fi
} }
@@ -372,7 +379,12 @@ ensure_homebrew() {
fi fi
info "Installing Homebrew first..." info "Installing Homebrew first..."
/bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" # When piped (curl | bash), stdin is the pipe — Homebrew needs TTY for sudo password prompt
if [[ -e /dev/tty ]]; then
/bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" < /dev/tty
else
NONINTERACTIVE=1 /bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
# Add Homebrew to PATH for Apple Silicon # Add Homebrew to PATH for Apple Silicon
if [[ -f /opt/homebrew/bin/brew ]]; then if [[ -f /opt/homebrew/bin/brew ]]; then
@@ -787,9 +799,84 @@ setup_sc_alias() {
} }
# ============================================================================ # ============================================================================
# Systemd Service Setup (Linux only) # Service Setup (Linux systemd / macOS launchd)
# ============================================================================ # ============================================================================
setup_launchd_service() {
local plist_label="com.codeman.web"
local agent_dir="$HOME/Library/LaunchAgents"
local agent_plist="$agent_dir/$plist_label.plist"
local daemon_plist="/Library/LaunchDaemons/$plist_label.plist"
info "Setting up macOS LaunchAgent..."
# Remove any existing LaunchDaemon (system-level) to prevent duplicates.
# We standardize on LaunchAgent (user-level) — it doesn't require sudo,
# inherits the user's environment, and is the correct choice for user apps.
if [[ -f "$daemon_plist" ]]; then
warn "Found system-level LaunchDaemon at $daemon_plist — removing to prevent duplicate"
sudo launchctl unload "$daemon_plist" 2>/dev/null || true
sudo rm -f "$daemon_plist"
success "Removed duplicate LaunchDaemon"
fi
# Unload existing agent before overwriting
if [[ -f "$agent_plist" ]]; then
launchctl unload "$agent_plist" 2>/dev/null || true
fi
mkdir -p "$agent_dir"
# Build PATH: ensure /opt/homebrew/bin (Apple Silicon) and ~/.local/bin are included
local svc_path="/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
# Find node binary path
local node_path
node_path=$(command -v node)
cat > "$agent_plist" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$plist_label</string>
<key>ProgramArguments</key>
<array>
<string>$node_path</string>
<string>$INSTALL_DIR/dist/index.js</string>
<string>web</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>$svc_path</string>
<key>HOME</key>
<string>$HOME</string>
<key>LANG</key>
<string>en_US.UTF-8</string>
</dict>
<key>WorkingDirectory</key>
<string>$HOME</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>/tmp/codeman.log</string>
<key>StandardErrorPath</key>
<string>/tmp/codeman.log</string>
</dict>
</plist>
EOF
launchctl load "$agent_plist" 2>/dev/null || true
success "LaunchAgent installed and started"
}
setup_systemd_service() { setup_systemd_service() {
local service_dir="$HOME/.config/systemd/user" local service_dir="$HOME/.config/systemd/user"
local service_file="$service_dir/codeman-web.service" local service_file="$service_dir/codeman-web.service"
@@ -1139,17 +1226,25 @@ main() {
echo "" echo ""
local launch_choice="" local launch_choice=""
local has_systemd=false local has_service=false
local service_type=""
if [[ "$os" == "linux" ]] && [[ "$SKIP_SYSTEMD" != "1" ]] && command -v systemctl &>/dev/null; then if [[ "$os" == "linux" ]] && [[ "$SKIP_SYSTEMD" != "1" ]] && command -v systemctl &>/dev/null; then
has_systemd=true has_service=true
service_type="systemd"
elif [[ "$os" == "macos" ]] && [[ "$SKIP_SYSTEMD" != "1" ]]; then
has_service=true
service_type="launchd"
fi fi
if [[ "$has_systemd" == "true" ]]; then if [[ "$has_service" == "true" ]]; then
local service_label="systemd service"
[[ "$service_type" == "launchd" ]] && service_label="LaunchAgent"
echo -e " ${BOLD}How would you like to run Codeman?${NC}" echo -e " ${BOLD}How would you like to run Codeman?${NC}"
echo "" echo ""
echo -e " ${CYAN}1)${NC} Run now in this terminal" echo -e " ${CYAN}1)${NC} Run now in this terminal"
echo -e " ${CYAN}2)${NC} Install as systemd service (auto-start on boot)" echo -e " ${CYAN}2)${NC} Install as $service_label (auto-start on boot)"
echo -e " ${CYAN}3)${NC} Don't start — I'll run it later" echo -e " ${CYAN}3)${NC} Don't start — I'll run it later"
echo "" echo ""
@@ -1166,7 +1261,7 @@ main() {
done done
fi fi
else else
# macOS or no systemd — only offer run now or skip # No service manager available — only offer run now or skip
echo -e " ${BOLD}Would you like to start Codeman now?${NC}" echo -e " ${BOLD}Would you like to start Codeman now?${NC}"
echo "" echo ""
echo -e " ${CYAN}1)${NC} Run now in this terminal" echo -e " ${CYAN}1)${NC} Run now in this terminal"
@@ -1192,12 +1287,16 @@ main() {
echo "" echo ""
# Handle systemd setup # Handle service setup
if [[ "$launch_choice" == "2" ]]; then if [[ "$launch_choice" == "2" ]]; then
setup_systemd_service if [[ "$service_type" == "launchd" ]]; then
setup_launchd_service
else
setup_systemd_service
fi
# Offer tunnel service if cloudflared is available # Offer tunnel service if cloudflared is available (Linux only — systemd tunnel service)
if check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then if [[ "$service_type" == "systemd" ]] && check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
echo "" echo ""
if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then
setup_tunnel_service setup_tunnel_service
@@ -1212,10 +1311,16 @@ main() {
echo "" echo ""
echo -e " ${BOLD}Manage the service:${NC}" echo -e " ${BOLD}Manage the service:${NC}"
echo "" echo ""
echo -e " ${CYAN}systemctl --user stop codeman-web${NC} # Stop" if [[ "$service_type" == "launchd" ]]; then
echo -e " ${CYAN}systemctl --user restart codeman-web${NC} # Restart" echo -e " ${CYAN}launchctl unload ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Stop"
echo -e " ${CYAN}systemctl --user status codeman-web${NC} # Check status" echo -e " ${CYAN}launchctl load ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Start"
echo -e " ${CYAN}journalctl --user -u codeman-web -f${NC} # View logs" echo -e " ${CYAN}tail -f /tmp/codeman.log${NC} # View logs"
else
echo -e " ${CYAN}systemctl --user stop codeman-web${NC} # Stop"
echo -e " ${CYAN}systemctl --user restart codeman-web${NC} # Restart"
echo -e " ${CYAN}systemctl --user status codeman-web${NC} # Check status"
echo -e " ${CYAN}journalctl --user -u codeman-web -f${NC} # View logs"
fi
echo "" echo ""
fi fi
@@ -1289,11 +1394,17 @@ update() {
success "Updated to $(node -e "console.log(require('./package.json').version)")" success "Updated to $(node -e "console.log(require('./package.json').version)")"
echo "" echo ""
# Auto-restart systemd service if it's running, otherwise tell the user # Auto-restart service if running, otherwise tell the user
if systemctl --user is-active codeman-web.service &>/dev/null; then local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then
info "Restarting codeman-web service..." info "Restarting codeman-web service..."
systemctl --user restart codeman-web.service systemctl --user restart codeman-web.service
success "codeman-web service restarted" success "codeman-web service restarted"
elif [[ -f "$agent_plist" ]]; then
info "Restarting LaunchAgent..."
launchctl unload "$agent_plist" 2>/dev/null || true
launchctl load "$agent_plist" 2>/dev/null || true
success "LaunchAgent restarted"
else else
echo -e " ${DIM}Restart codeman web to use the new version:${NC}" echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}" echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}"
@@ -1306,9 +1417,9 @@ uninstall() {
info "Uninstalling Codeman..." info "Uninstalling Codeman..."
echo "" echo ""
# Stop and remove systemd services # Stop and remove systemd services (Linux)
for svc in codeman-web codeman-tunnel; do for svc in codeman-web codeman-tunnel; do
if systemctl --user is-active "${svc}.service" &>/dev/null; then if systemctl --user is-active "${svc}.service" &>/dev/null 2>&1; then
info "Stopping ${svc} service..." info "Stopping ${svc} service..."
systemctl --user stop "${svc}.service" systemctl --user stop "${svc}.service"
fi fi
@@ -1324,6 +1435,20 @@ uninstall() {
done done
systemctl --user daemon-reload 2>/dev/null || true systemctl --user daemon-reload 2>/dev/null || true
# Stop and remove launchd services (macOS)
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
local daemon_plist="/Library/LaunchDaemons/com.codeman.web.plist"
if [[ -f "$agent_plist" ]]; then
launchctl unload "$agent_plist" 2>/dev/null || true
rm -f "$agent_plist"
success "Removed LaunchAgent"
fi
if [[ -f "$daemon_plist" ]]; then
sudo launchctl unload "$daemon_plist" 2>/dev/null || true
sudo rm -f "$daemon_plist"
success "Removed LaunchDaemon"
fi
# Remove symlinks # Remove symlinks
local symlink_dir="$HOME/.local/bin" local symlink_dir="$HOME/.local/bin"
if [[ -L "$symlink_dir/codeman" ]]; then if [[ -L "$symlink_dir/codeman" ]]; then
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "0.5.6", "version": "0.5.10",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -15,12 +15,12 @@
"dev": "tsx src/index.ts web", "dev": "tsx src/index.ts web",
"web": "node dist/index.js web", "web": "node dist/index.js web",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"test": "vitest run", "test": "vitest run --config config/vitest.config.ts",
"test:watch": "vitest", "test:watch": "vitest --config config/vitest.config.ts",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --config config/vitest.config.ts --coverage",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint 'src/**/*.ts'", "lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
"lint:fix": "eslint 'src/**/*.ts' --fix", "lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
"format": "prettier --write 'src/**/*.ts'", "format": "prettier --write 'src/**/*.ts'",
"format:check": "prettier --check 'src/**/*.ts'", "format:check": "prettier --check 'src/**/*.ts'",
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs", "capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
-1
View File
@@ -1 +0,0 @@
tools/remotion/public
+1 -1
View File
@@ -428,7 +428,7 @@ const SUBAGENT_ACTIVITY = {
'agent-002': [ 'agent-002': [
{ type: 'tool', tool: 'Glob', input: { pattern: 'test/**/*.test.ts' }, timestamp: new Date().toISOString(), agentId: '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/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' }, { type: 'message', role: 'assistant', text: 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...', timestamp: new Date().toISOString(), agentId: 'agent-002' },
], ],
}; };
+14 -14
View File
@@ -9,7 +9,7 @@
* *
* Usage: node scripts/capture-video-screenshots.mjs * Usage: node scripts/capture-video-screenshots.mjs
* Port: 3198 (static file server) * Port: 3198 (static file server)
* Output: remotion/public/ (6 PNGs) * Output: scripts/scripts/remotion/public/ (6 PNGs)
*/ */
import { chromium } from 'playwright'; import { chromium } from 'playwright';
@@ -21,7 +21,7 @@ import { fileURLToPath } from 'url';
const __dirname = fileURLToPath(new URL('.', import.meta.url)); const __dirname = fileURLToPath(new URL('.', import.meta.url));
const PROJECT_ROOT = join(__dirname, '..'); const PROJECT_ROOT = join(__dirname, '..');
const PUBLIC_DIR = join(PROJECT_ROOT, 'src', 'web', 'public'); 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 PORT = 3198;
const DESKTOP_VIEWPORT = { width: 1920, height: 1080 }; const DESKTOP_VIEWPORT = { width: 1920, height: 1080 };
@@ -516,7 +516,7 @@ async function captureDesktopWelcome(browser) {
path: join(OUTPUT_DIR, 'desktop-welcome.png'), path: join(OUTPUT_DIR, 'desktop-welcome.png'),
fullPage: false, fullPage: false,
}); });
console.log(' Saved: remotion/public/desktop-welcome.png'); console.log(' Saved: scripts/remotion/public/desktop-welcome.png');
} finally { } finally {
await context.close(); await context.close();
} }
@@ -545,7 +545,7 @@ async function captureDesktopClaude(browser) {
path: join(OUTPUT_DIR, 'desktop-claude.png'), path: join(OUTPUT_DIR, 'desktop-claude.png'),
fullPage: false, fullPage: false,
}); });
console.log(' Saved: remotion/public/desktop-claude.png'); console.log(' Saved: scripts/remotion/public/desktop-claude.png');
} finally { } finally {
await context.close(); await context.close();
} }
@@ -575,7 +575,7 @@ async function captureDesktopBothClaude(browser) {
path: join(OUTPUT_DIR, 'desktop-both-claude.png'), path: join(OUTPUT_DIR, 'desktop-both-claude.png'),
fullPage: false, fullPage: false,
}); });
console.log(' Saved: remotion/public/desktop-both-claude.png'); console.log(' Saved: scripts/remotion/public/desktop-both-claude.png');
} finally { } finally {
await context.close(); await context.close();
} }
@@ -605,7 +605,7 @@ async function captureDesktopBothOpencode(browser) {
path: join(OUTPUT_DIR, 'desktop-both-opencode.png'), path: join(OUTPUT_DIR, 'desktop-both-opencode.png'),
fullPage: false, fullPage: false,
}); });
console.log(' Saved: remotion/public/desktop-both-opencode.png'); console.log(' Saved: scripts/remotion/public/desktop-both-opencode.png');
} finally { } finally {
await context.close(); await context.close();
} }
@@ -634,7 +634,7 @@ async function captureMobileClaude(browser) {
path: join(OUTPUT_DIR, 'mobile-claude.png'), path: join(OUTPUT_DIR, 'mobile-claude.png'),
fullPage: false, fullPage: false,
}); });
console.log(' Saved: remotion/public/mobile-claude.png'); console.log(' Saved: scripts/remotion/public/mobile-claude.png');
} finally { } finally {
await context.close(); await context.close();
} }
@@ -663,7 +663,7 @@ async function captureMobileOpencode(browser) {
path: join(OUTPUT_DIR, 'mobile-opencode.png'), path: join(OUTPUT_DIR, 'mobile-opencode.png'),
fullPage: false, fullPage: false,
}); });
console.log(' Saved: remotion/public/mobile-opencode.png'); console.log(' Saved: scripts/remotion/public/mobile-opencode.png');
} finally { } finally {
await context.close(); await context.close();
} }
@@ -706,12 +706,12 @@ async function main() {
console.log('All 6 screenshots captured!'); console.log('All 6 screenshots captured!');
console.log('='.repeat(60)); console.log('='.repeat(60));
console.log('\nOutput files:'); console.log('\nOutput files:');
console.log(' remotion/public/desktop-welcome.png'); console.log(' scripts/remotion/public/desktop-welcome.png');
console.log(' remotion/public/desktop-claude.png'); console.log(' scripts/remotion/public/desktop-claude.png');
console.log(' remotion/public/desktop-both-claude.png'); console.log(' scripts/remotion/public/desktop-both-claude.png');
console.log(' remotion/public/desktop-both-opencode.png'); console.log(' scripts/remotion/public/desktop-both-opencode.png');
console.log(' remotion/public/mobile-claude.png'); console.log(' scripts/remotion/public/mobile-claude.png');
console.log(' remotion/public/mobile-opencode.png'); console.log(' scripts/remotion/public/mobile-opencode.png');
} catch (err) { } catch (err) {
console.error('\nFatal error:', err.message); console.error('\nFatal error:', err.message);
console.error(err.stack); console.error(err.stack);
-33
View File
@@ -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"

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Before

Width:  |  Height:  |  Size: 390 KiB

After

Width:  |  Height:  |  Size: 390 KiB

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+11
View File
@@ -274,6 +274,7 @@ export class Session extends EventEmitter {
private _lastPromptTime: number = 0; private _lastPromptTime: number = 0;
private activityTimeout: NodeJS.Timeout | null = null; private activityTimeout: NodeJS.Timeout | null = null;
private _awaitingIdleConfirmation: boolean = false; // Prevents timeout reset during idle detection private _awaitingIdleConfirmation: boolean = false; // Prevents timeout reset during idle detection
private _trustDialogAccepted: boolean = false; // Prevents repeated trust dialog auto-accept
private _taskTracker: TaskTracker; private _taskTracker: TaskTracker;
// Token tracking for auto-clear // Token tracking for auto-clear
@@ -1118,6 +1119,16 @@ export class Session extends EventEmitter {
this._handleTerminalOutput(data); this._handleTerminalOutput(data);
// === Auto-accept workspace trust dialog ===
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
// Codeman sessions always use --dangerously-skip-permissions, so auto-accept.
if (!this._trustDialogAccepted && data.includes('trust this folder')) {
this._trustDialogAccepted = true;
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
// Send Enter to accept the default selection ("Yes, I trust this folder")
this.writeViaMux('\r');
}
// === Idle/working detection runs on every chunk (latency-sensitive) === // === Idle/working detection runs on every chunk (latency-sensitive) ===
// Detect if Claude is working or at prompt // Detect if Claude is working or at prompt
// The prompt line contains "❯" when waiting for input // The prompt line contains "❯" when waiting for input
+2 -2
View File
@@ -185,8 +185,8 @@ function buildSpawnCommand(options: {
}): string { }): string {
if (options.mode === 'claude') { if (options.mode === 'claude') {
// Validate model to prevent command injection // Validate model to prevent command injection
const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined; const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined;
const modelFlag = safeModel ? ` --model ${safeModel}` : ''; const modelFlag = safeModel ? ` --model "${safeModel}"` : '';
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions. // Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
// Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.), // Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
// fall back to a new session with --session-id so the pane doesn't die. // fall back to a new session with --session-id so the pane doesn't die.
+6 -2
View File
@@ -523,6 +523,9 @@ class CodemanApp {
SwipeHandler.init(); SwipeHandler.init();
VoiceInput.init(); VoiceInput.init();
KeyboardAccessoryBar.init(); KeyboardAccessoryBar.init();
// Apply keyboard bar mode from settings
const _kbSettings = this.loadAppSettingsFromStorage();
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
this.applyHeaderVisibilitySettings(); this.applyHeaderVisibilitySettings();
this.applyTabWrapSettings(); this.applyTabWrapSettings();
this.applyMonitorVisibility(); this.applyMonitorVisibility();
@@ -2105,8 +2108,9 @@ class CodemanApp {
// Track working directory for path normalization in Project Insights // Track working directory for path normalization in Project Insights
this.currentSessionWorkingDir = session?.workingDir || null; this.currentSessionWorkingDir = session?.workingDir || null;
if (session && session.pid === null && session.status === 'idle') { if (session && session.pid === null && !session._ended) {
// This is a restored session - attach to the existing screen/shell // Session has no PTY attached — either restored after server restart
// or detached for some other reason. Re-attach regardless of status.
try { try {
const endpoint = session.mode === 'shell' const endpoint = session.mode === 'shell'
? `/api/sessions/${sessionId}/shell` ? `/api/sessions/${sessionId}/shell`
+12 -1
View File
@@ -892,6 +892,17 @@
</label> </label>
</div> </div>
<div class="settings-item settings-item-multiline" title="Show additional buttons (Tab, Shift+Tab, Ctrl+O, Esc, Alt+Enter, left/right arrows) in the mobile keyboard accessory bar.">
<div class="settings-item-text">
<span class="settings-item-label">Extended Keyboard Bar</span>
<span class="settings-item-desc">Extra keys: Tab, Esc, arrows, Ctrl+O</span>
</div>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsExtendedKeyboardBar">
<span class="slider"></span>
</label>
</div>
<!-- Header Displays Section --> <!-- Header Displays Section -->
<div class="settings-section-header">Header Displays</div> <div class="settings-section-header">Header Displays</div>
<div class="settings-item" title="Show A-/A+ font size buttons in header"> <div class="settings-item" title="Show A-/A+ font size buttons in header">
@@ -1393,7 +1404,7 @@
<div class="case-manage-list" id="caseManageList"> <div class="case-manage-list" id="caseManageList">
<!-- Populated by JS --> <!-- Populated by JS -->
</div> </div>
<span class="form-hint" style="margin-top: 8px; display: block;">Drag or use arrows to reorder. Changes are saved automatically.</span> <span class="form-hint" style="margin-top: 8px; display: block;">Use arrows to reorder. Changes are saved automatically.</span>
</div> </div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
+53 -14
View File
@@ -4,7 +4,7 @@
* Defines two exports: * Defines two exports:
* *
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual * - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
* keyboard on mobile: 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). * Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
* Commands are sent as text + Enter separately for Ink compatibility. * Commands are sent as text + Enter separately for Ink compatibility.
* Only initializes on touch devices (MobileDetection.isTouchDevice guard). * Only initializes on touch devices (MobileDetection.isTouchDevice guard).
@@ -33,16 +33,37 @@
*/ */
const KeyboardAccessoryBar = { const KeyboardAccessoryBar = {
element: null, element: null,
_mode: 'simple', // 'simple' or 'extended'
/** Create and inject the accessory bar */ /** HTML for simple mode: arrows, commands, paste, dismiss */
init() { _simpleButtons: `
// Only on mobile <button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
if (!MobileDetection.isTouchDevice()) return; <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M5 15l7-7 7 7"/>
</svg>
</button>
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-down" title="Arrow down">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M19 9l-7 7-7-7"/>
</svg>
</button>
<button class="accessory-btn" data-action="init" title="/init">/init</button>
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
</svg>
</button>
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M19 9l-7 7-7-7"/>
</svg>
</button>`,
// Create accessory bar element /** HTML for extended mode: all keys including arrows, Tab, Esc, etc. */
this.element = document.createElement('div'); _extendedButtons: `
this.element.className = 'keyboard-accessory-bar';
this.element.innerHTML = `
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up"> <button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M5 15l7-7 7 7"/> <path d="M5 15l7-7 7 7"/>
@@ -77,12 +98,21 @@ const KeyboardAccessoryBar = {
<button class="accessory-btn" data-action="init" title="/init">/init</button> <button class="accessory-btn" data-action="init" title="/init">/init</button>
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button> <button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button> <button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
<button class="accessory-btn accessory-btn-arrow" data-action="dismiss" title="Dismiss keyboard"> <button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M19 9l-7 7-7-7"/> <path d="M19 9l-7 7-7-7"/>
</svg> </svg>
</button> </button>`,
`;
/** Create and inject the accessory bar */
init() {
// Only on mobile
if (!MobileDetection.isTouchDevice()) return;
// Create accessory bar element
this.element = document.createElement('div');
this.element.className = 'keyboard-accessory-bar';
this.element.innerHTML = this._simpleButtons;
// Add click handlers — preventDefault stops event from reaching terminal // Add click handlers — preventDefault stops event from reaching terminal
this.element.addEventListener('click', (e) => { this.element.addEventListener('click', (e) => {
@@ -95,7 +125,8 @@ const KeyboardAccessoryBar = {
this.handleAction(action, btn); this.handleAction(action, btn);
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts) // Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
if ((action === 'scroll-up' || action === 'scroll-down' || action === 'arrow-left' || action === 'arrow-right' || action === 'tab' || action === 'shift-tab' || action === 'ctrl-o' || action === 'opt-enter' || action === 'esc') || const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc']);
if (refocusActions.has(action) ||
((action === 'clear' || action === 'compact') && this._confirmAction)) { ((action === 'clear' || action === 'compact') && this._confirmAction)) {
if (typeof app !== 'undefined' && app.terminal) { if (typeof app !== 'undefined' && app.terminal) {
app.terminal.focus(); app.terminal.focus();
@@ -110,6 +141,14 @@ const KeyboardAccessoryBar = {
} }
}, },
/** Switch between 'simple' and 'extended' button layouts */
setMode(mode) {
if (mode === this._mode || !this.element) return;
this._mode = mode;
this.clearConfirm();
this.element.innerHTML = mode === 'extended' ? this._extendedButtons : this._simpleButtons;
},
_confirmTimer: null, _confirmTimer: null,
_confirmAction: null, _confirmAction: null,
+51 -12
View File
@@ -943,13 +943,33 @@ html.mobile-init .file-browser-panel {
.accessory-btn-arrow { .accessory-btn-arrow {
padding: 6px 10px; padding: 6px 10px;
background: #1e3a5f; background: #2563eb;
border-color: rgba(59, 130, 246, 0.3); border-color: rgba(59, 130, 246, 0.5);
color: #93c5fd; color: #fff;
} }
.accessory-btn-arrow:active { .accessory-btn-arrow:active {
background: #1d4ed8;
}
.accessory-btn-dismiss {
margin-left: auto;
flex: 1 1 0;
max-width: 100px;
padding: 10px 8px;
background: #2563eb; background: #2563eb;
border-color: rgba(59, 130, 246, 0.5);
color: #fff;
font-weight: 600;
}
.accessory-btn-dismiss svg {
width: 22px;
height: 22px;
}
.accessory-btn-dismiss:active {
background: #1d4ed8;
} }
/* Voice preview — positioned above accessory bar on mobile */ /* Voice preview — positioned above accessory bar on mobile */
@@ -981,9 +1001,9 @@ html.mobile-init .file-browser-panel {
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.6);
z-index: 10000; z-index: 10000;
display: flex; display: flex;
align-items: flex-end; align-items: flex-start;
justify-content: center; justify-content: center;
padding-bottom: env(safe-area-inset-bottom, 12px); padding-top: 15vh;
} }
.paste-dialog { .paste-dialog {
@@ -993,13 +1013,12 @@ html.mobile-init .file-browser-panel {
padding: 12px; padding: 12px;
width: calc(100% - 24px); width: calc(100% - 24px);
max-width: 400px; max-width: 400px;
margin-bottom: 8px;
} }
.paste-textarea { .paste-textarea {
width: 100%; width: 100%;
min-height: 60px; min-height: 80px;
max-height: 120px; max-height: 200px;
background: var(--bg-primary, #0d0d14); background: var(--bg-primary, #0d0d14);
color: var(--text-primary, #e0e0e0); color: var(--text-primary, #e0e0e0);
border: 1px solid var(--border-color, #444); border: 1px solid var(--border-color, #444);
@@ -2110,13 +2129,33 @@ html.mobile-init .file-browser-panel {
.accessory-btn-arrow { .accessory-btn-arrow {
padding: 6px 10px; padding: 6px 10px;
background: #1e3a5f; background: #2563eb;
border-color: rgba(59, 130, 246, 0.3); border-color: rgba(59, 130, 246, 0.5);
color: #93c5fd; color: #fff;
} }
.accessory-btn-arrow:active { .accessory-btn-arrow:active {
background: #2563eb; background: #1d4ed8;
}
.accessory-btn-dismiss {
margin-left: auto;
flex: 1 1 0;
max-width: 80px;
padding: 10px 8px;
background: #334d6e;
border-color: rgba(100, 150, 200, 0.4);
color: #c0d4e8;
font-weight: 600;
}
.accessory-btn-dismiss svg {
width: 20px;
height: 20px;
}
.accessory-btn-dismiss:active {
background: #3d5f85;
} }
/* ============================================================================ /* ============================================================================
+7 -2
View File
@@ -319,6 +319,7 @@ Object.assign(CodemanApp.prototype, {
this.loadTunnelStatus(); this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false; document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false;
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false; document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
// Claude CLI settings // Claude CLI settings
const claudeModeSelect = document.getElementById('appSettingsClaudeMode'); const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
@@ -1125,6 +1126,7 @@ Object.assign(CodemanApp.prototype, {
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked, tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked, localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked, cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked, tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
// Claude CLI settings // Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value, claudeMode: document.getElementById('appSettingsClaudeMode').value,
@@ -1245,9 +1247,12 @@ Object.assign(CodemanApp.prototype, {
// Apply CJK input visibility immediately // Apply CJK input visibility immediately
this._updateCjkInputState(); this._updateCjkInputState();
// Apply keyboard bar mode
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
// Save to server (includes notification prefs for cross-browser persistence) // Save to server (includes notification prefs for cross-browser persistence)
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform // Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, ...serverSettings } = settings; const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, ...serverSettings } = settings;
try { try {
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings }); await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
@@ -1631,7 +1636,7 @@ Object.assign(CodemanApp.prototype, {
const displayKeys = new Set([ const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost', 'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
]); ]);
// Merge settings: non-display keys always sync from server, // Merge settings: non-display keys always sync from server,
// display keys only seed from server when localStorage has no value // display keys only seed from server when localStorage has no value
+3 -2
View File
@@ -19,6 +19,7 @@ import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort } from '../ports/index.js'; import type { EventPort, ConfigPort } from '../ports/index.js';
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json'); const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */ /** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
async function readLinkedCases(): Promise<Record<string, string>> { async function readLinkedCases(): Promise<Record<string, string>> {
@@ -46,7 +47,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
try { try {
const entries = await fs.readdir(CASES_DIR, { withFileTypes: true }); const entries = await fs.readdir(CASES_DIR, { withFileTypes: true });
for (const e of entries) { for (const e of entries) {
if (e.isDirectory()) { if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
cases.push({ cases.push({
name: e.name, name: e.name,
path: join(CASES_DIR, e.name), path: join(CASES_DIR, e.name),
@@ -62,7 +63,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const linkedCases = await readLinkedCases(); const linkedCases = await readLinkedCases();
const existingNames = new Set(cases.map((c) => c.name)); const existingNames = new Set(cases.map((c) => c.name));
for (const [name, path] of Object.entries(linkedCases)) { for (const [name, path] of Object.entries(linkedCases)) {
if (!existingNames.has(name) && existsSync(path)) { if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
cases.push({ cases.push({
name, name,
path, path,
+23 -9
View File
@@ -544,7 +544,8 @@ export class WebServer extends EventEmitter {
cacheControl: false, cacheControl: false,
preCompressed: true, preCompressed: true,
setHeaders: (res, path) => { setHeaders: (res, path) => {
if (path.endsWith('.html')) { // Use .includes() not .endsWith() — preCompressed serves .html.br/.html.gz
if (path.includes('.html')) {
res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Cache-Control', 'no-cache');
} else { } else {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
@@ -1766,15 +1767,28 @@ export class WebServer extends EventEmitter {
this.sessions.set(session.id, session); this.sessions.set(session.id, session);
await this.setupSessionListeners(session); await this.setupSessionListeners(session);
this.persistSessionState(session);
// Mark it as restored (not started yet - user needs to attach) // Auto-attach PTY to the surviving tmux session immediately.
getLifecycleLog().log({ // This ensures ALL sessions resume capturing output right away,
event: 'recovered', // not just the one the client happens to select first.
sessionId: session.id, try {
name: session.name, await session.startInteractive();
}); getLifecycleLog().log({
console.log(`[Server] Restored session ${session.id} from mux ${muxSession.muxName}`); event: 'recovered',
sessionId: session.id,
name: session.name,
});
console.log(`[Server] Restored and attached session ${session.id} from mux ${muxSession.muxName}`);
} catch (attachErr) {
console.error(`[Server] Failed to attach session ${session.id}, keeping as detached:`, attachErr);
getLifecycleLog().log({
event: 'recovered',
sessionId: session.id,
name: session.name,
});
}
this.persistSessionState(session);
} }
} }
-4
View File
@@ -1,4 +0,0 @@
{
"status": "failed",
"failedTests": []
}
@@ -18,20 +18,20 @@ Validates Codeman's mobile UI across 135 devices, covering:
```bash ```bash
# Run all mobile tests # Run all mobile tests
npx vitest run --config mobile-test/vitest.config.ts npx vitest run --config test/mobile/vitest.config.ts
# Run a single test file # Run a single test file
npx vitest run --config mobile-test/vitest.config.ts mobile-test/keyboard.test.ts npx vitest run --config test/mobile/vitest.config.ts test/mobile/keyboard.test.ts
# Quick mode — 6 representative devices, skip full matrix # Quick mode — 6 representative devices, skip full matrix
CI_QUICK=1 npx vitest run --config mobile-test/vitest.config.ts CI_QUICK=1 npx vitest run --config test/mobile/vitest.config.ts
# Full device matrix only (135 devices) # Full device matrix only (135 devices)
npx vitest run --config mobile-test/vitest.config.ts mobile-test/device-matrix.test.ts npx vitest run --config test/mobile/vitest.config.ts test/mobile/device-matrix.test.ts
# Update visual baselines (delete old baselines, re-run) # Update visual baselines (delete old baselines, re-run)
rm -rf mobile-test/snapshots/*.png rm -rf test/mobile/snapshots/*.png
npx vitest run --config mobile-test/vitest.config.ts mobile-test/visual-regression.test.ts npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regression.test.ts
``` ```
## Test Files ## Test Files
@@ -126,7 +126,7 @@ Swipe simulation includes intermediate move points and timing to satisfy `SwipeH
Uses `pixelmatch` + `pngjs` (both in devDeps) for pixel-level comparison: Uses `pixelmatch` + `pngjs` (both in devDeps) for pixel-level comparison:
- **Baselines** stored in `mobile-test/snapshots/` (git-tracked) - **Baselines** stored in `test/mobile/snapshots/` (git-tracked)
- **Tolerance**: 0.5% pixel diff (handles anti-aliasing) - **Tolerance**: 0.5% pixel diff (handles anti-aliasing)
- **On failure**: `.actual.png` and `.diff.png` generated (gitignored) - **On failure**: `.actual.png` and `.diff.png` generated (gitignored)
- **Update**: Delete baseline PNGs and re-run — new baselines auto-created - **Update**: Delete baseline PNGs and re-run — new baselines auto-created
@@ -96,7 +96,7 @@ export const SUBAGENT = {
export const VISUAL = { export const VISUAL = {
DEFAULT_THRESHOLD: 0.1, DEFAULT_THRESHOLD: 0.1,
MAX_DIFF_PERCENT: 0.5, MAX_DIFF_PERCENT: 0.5,
SNAPSHOT_DIR: 'mobile-test/snapshots', SNAPSHOT_DIR: 'test/mobile/snapshots',
} as const; } as const;
// Visual regression breakpoints (width values for screenshot comparison) // Visual regression breakpoints (width values for screenshot comparison)

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

@@ -166,19 +166,19 @@ describe('Visual Regression', () => {
* To update visual baselines: * To update visual baselines:
* *
* 1. Delete the specific snapshot: * 1. Delete the specific snapshot:
* rm mobile-test/snapshots/<name>.png * rm test/mobile/snapshots/<name>.png
* *
* 2. Re-run the test: * 2. Re-run the test:
* npx vitest run mobile-test/visual-regression.test.ts * npx vitest run test/mobile/visual-regression.test.ts
* *
* 3. Review the new baseline: * 3. Review the new baseline:
* The test will create a fresh .png and pass (isNewBaseline = true). * The test will create a fresh .png and pass (isNewBaseline = true).
* *
* To update ALL baselines: * To update ALL baselines:
* rm -rf mobile-test/snapshots/ && npx vitest run mobile-test/visual-regression.test.ts * rm -rf test/mobile/snapshots/ && npx vitest run test/mobile/visual-regression.test.ts
* *
* Diff images for failed comparisons are saved as <name>.diff.png * Diff images for failed comparisons are saved as <name>.diff.png
* alongside the baseline in mobile-test/snapshots/. * alongside the baseline in test/mobile/snapshots/.
*/ */
it('placeholder — see comments above for baseline update instructions', () => { it('placeholder — see comments above for baseline update instructions', () => {
expect(true).toBe(true); expect(true).toBe(true);
@@ -1,10 +1,14 @@
import { resolve } from 'node:path';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
const root = resolve(import.meta.dirname, '../..');
export default defineConfig({ export default defineConfig({
test: { test: {
root,
globals: true, globals: true,
environment: 'node', environment: 'node',
include: ['mobile-test/**/*.test.ts'], include: ['test/mobile/**/*.test.ts'],
setupFiles: ['./test/setup.ts'], setupFiles: ['./test/setup.ts'],
fileParallelism: false, fileParallelism: false,
testTimeout: 60_000, testTimeout: 60_000,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB