Merge remote-tracking branch 'origin/master' into dev
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/<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:
|
||||
|
||||
```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 '<?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>
|
||||
|
||||
|
||||
@@ -22,8 +22,7 @@ export default tseslint.config(
|
||||
'src/web/public/vendor/**',
|
||||
'src/web/public/app.js',
|
||||
'scripts/**/*.mjs',
|
||||
'tools/**',
|
||||
'remotion/**',
|
||||
'scripts/remotion/**',
|
||||
],
|
||||
}
|
||||
);
|
||||
@@ -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'],
|
||||
@@ -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
|
||||
@@ -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
|
||||
<?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() {
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -892,6 +892,17 @@
|
||||
</label>
|
||||
</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 -->
|
||||
<div class="settings-section-header">Header Displays</div>
|
||||
<div class="settings-item" title="Show A-/A+ font size buttons in header">
|
||||
@@ -1393,7 +1404,7 @@
|
||||
<div class="case-manage-list" id="caseManageList">
|
||||
<!-- Populated by JS -->
|
||||
</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 class="form-actions">
|
||||
|
||||
@@ -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: `
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M5 15l7-7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button 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
|
||||
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: `
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M5 15l7-7 7 7"/>
|
||||
@@ -77,12 +98,21 @@ const KeyboardAccessoryBar = {
|
||||
<button class="accessory-btn" data-action="init" title="/init">/init</button>
|
||||
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
|
||||
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="dismiss" title="Dismiss keyboard">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<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>
|
||||
`;
|
||||
</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
|
||||
this.element.addEventListener('click', (e) => {
|
||||
@@ -95,7 +125,8 @@ const KeyboardAccessoryBar = {
|
||||
this.handleAction(action, btn);
|
||||
|
||||
// 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)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
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,
|
||||
_confirmAction: null,
|
||||
|
||||
|
||||
@@ -943,13 +943,33 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
.accessory-btn-arrow {
|
||||
padding: 6px 10px;
|
||||
background: #1e3a5f;
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
color: #93c5fd;
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow:active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss {
|
||||
margin-left: auto;
|
||||
flex: 1 1 0;
|
||||
max-width: 100px;
|
||||
padding: 10px 8px;
|
||||
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 */
|
||||
@@ -981,9 +1001,9 @@ html.mobile-init .file-browser-panel {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-bottom: env(safe-area-inset-bottom, 12px);
|
||||
padding-top: 15vh;
|
||||
}
|
||||
|
||||
.paste-dialog {
|
||||
@@ -993,13 +1013,12 @@ html.mobile-init .file-browser-panel {
|
||||
padding: 12px;
|
||||
width: calc(100% - 24px);
|
||||
max-width: 400px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.paste-textarea {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
max-height: 120px;
|
||||
min-height: 80px;
|
||||
max-height: 200px;
|
||||
background: var(--bg-primary, #0d0d14);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
@@ -2110,13 +2129,33 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
.accessory-btn-arrow {
|
||||
padding: 6px 10px;
|
||||
background: #1e3a5f;
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
color: #93c5fd;
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
|
||||
@@ -319,6 +319,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.loadTunnelStatus();
|
||||
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
// Claude CLI settings
|
||||
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
||||
@@ -1125,6 +1126,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
||||
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||
// Claude CLI settings
|
||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||
@@ -1245,9 +1247,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Apply CJK input visibility immediately
|
||||
this._updateCjkInputState();
|
||||
|
||||
// Apply keyboard bar mode
|
||||
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
|
||||
|
||||
// Save to server (includes notification prefs for cross-browser persistence)
|
||||
// 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 {
|
||||
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
|
||||
|
||||
@@ -1631,7 +1636,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const displayKeys = new Set([
|
||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
]);
|
||||
// Merge settings: non-display keys always sync from server,
|
||||
// display keys only seed from server when localStorage has no value
|
||||
|
||||
@@ -19,6 +19,7 @@ import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
|
||||
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
|
||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||
async function readLinkedCases(): Promise<Record<string, string>> {
|
||||
@@ -46,7 +47,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
try {
|
||||
const entries = await fs.readdir(CASES_DIR, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory()) {
|
||||
if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
|
||||
cases.push({
|
||||
name: e.name,
|
||||
path: join(CASES_DIR, e.name),
|
||||
@@ -62,7 +63,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
const linkedCases = await readLinkedCases();
|
||||
const existingNames = new Set(cases.map((c) => c.name));
|
||||
for (const [name, path] of Object.entries(linkedCases)) {
|
||||
if (!existingNames.has(name) && existsSync(path)) {
|
||||
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
|
||||
cases.push({
|
||||
name,
|
||||
path,
|
||||
|
||||
@@ -544,7 +544,8 @@ export class WebServer extends EventEmitter {
|
||||
cacheControl: false,
|
||||
preCompressed: true,
|
||||
setHeaders: (res, path) => {
|
||||
if (path.endsWith('.html')) {
|
||||
// Use .includes() not .endsWith() — preCompressed serves .html.br/.html.gz
|
||||
if (path.includes('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
} else {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
@@ -1766,15 +1767,28 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
await this.setupSessionListeners(session);
|
||||
this.persistSessionState(session);
|
||||
|
||||
// Mark it as restored (not started yet - user needs to attach)
|
||||
getLifecycleLog().log({
|
||||
event: 'recovered',
|
||||
sessionId: session.id,
|
||||
name: session.name,
|
||||
});
|
||||
console.log(`[Server] Restored session ${session.id} from mux ${muxSession.muxName}`);
|
||||
// Auto-attach PTY to the surviving tmux session immediately.
|
||||
// This ensures ALL sessions resume capturing output right away,
|
||||
// not just the one the client happens to select first.
|
||||
try {
|
||||
await session.startInteractive();
|
||||
getLifecycleLog().log({
|
||||
event: 'recovered',
|
||||
sessionId: session.id,
|
||||
name: session.name,
|
||||
});
|
||||
console.log(`[Server] Restored and attached session ${session.id} from mux ${muxSession.muxName}`);
|
||||
} catch (attachErr) {
|
||||
console.error(`[Server] Failed to attach session ${session.id}, keeping as detached:`, attachErr);
|
||||
getLifecycleLog().log({
|
||||
event: 'recovered',
|
||||
sessionId: session.id,
|
||||
name: session.name,
|
||||
});
|
||||
}
|
||||
|
||||
this.persistSessionState(session);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -18,20 +18,20 @@ Validates Codeman's mobile UI across 135 devices, covering:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
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
|
||||
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)
|
||||
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)
|
||||
rm -rf mobile-test/snapshots/*.png
|
||||
npx vitest run --config mobile-test/vitest.config.ts mobile-test/visual-regression.test.ts
|
||||
rm -rf test/mobile/snapshots/*.png
|
||||
npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regression.test.ts
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
- **Baselines** stored in `mobile-test/snapshots/` (git-tracked)
|
||||
- **Baselines** stored in `test/mobile/snapshots/` (git-tracked)
|
||||
- **Tolerance**: 0.5% pixel diff (handles anti-aliasing)
|
||||
- **On failure**: `.actual.png` and `.diff.png` generated (gitignored)
|
||||
- **Update**: Delete baseline PNGs and re-run — new baselines auto-created
|
||||
@@ -96,7 +96,7 @@ export const SUBAGENT = {
|
||||
export const VISUAL = {
|
||||
DEFAULT_THRESHOLD: 0.1,
|
||||
MAX_DIFF_PERCENT: 0.5,
|
||||
SNAPSHOT_DIR: 'mobile-test/snapshots',
|
||||
SNAPSHOT_DIR: 'test/mobile/snapshots',
|
||||
} as const;
|
||||
|
||||
// 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:
|
||||
*
|
||||
* 1. Delete the specific snapshot:
|
||||
* rm mobile-test/snapshots/<name>.png
|
||||
* rm test/mobile/snapshots/<name>.png
|
||||
*
|
||||
* 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:
|
||||
* The test will create a fresh .png and pass (isNewBaseline = true).
|
||||
*
|
||||
* 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
|
||||
* alongside the baseline in mobile-test/snapshots/.
|
||||
* alongside the baseline in test/mobile/snapshots/.
|
||||
*/
|
||||
it('placeholder — see comments above for baseline update instructions', () => {
|
||||
expect(true).toBe(true);
|
||||
@@ -1,10 +1,14 @@
|
||||
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: ['mobile-test/**/*.test.ts'],
|
||||
include: ['test/mobile/**/*.test.ts'],
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
fileParallelism: false,
|
||||
testTimeout: 60_000,
|
||||
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 20 KiB |