Merge remote-tracking branch 'origin/master' into dev
@@ -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
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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'],
|
||||||
@@ -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:
|
# Environment variables:
|
||||||
# CODEMAN_NONINTERACTIVE=1 - Skip all prompts (for CI/automation)
|
# CODEMAN_NONINTERACTIVE=1 - Skip all prompts (for CI/automation)
|
||||||
# CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app)
|
# CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app)
|
||||||
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd service setup prompt
|
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd/launchd service setup prompt
|
||||||
# CODEMAN_NODE_VERSION - Node.js major version to install (default: 22)
|
# CODEMAN_NODE_VERSION - Node.js major version to install (default: 22)
|
||||||
# CODEMAN_REPO_URL - Custom git repository URL (default: upstream Codeman)
|
# CODEMAN_REPO_URL - Custom git repository URL (default: upstream Codeman)
|
||||||
# CODEMAN_BRANCH - Git branch to install (default: master)
|
# CODEMAN_BRANCH - Git branch to install (default: master)
|
||||||
@@ -353,8 +353,15 @@ ensure_sudo() {
|
|||||||
die "sudo is required but not installed. Please install packages manually or run as root."
|
die "sudo is required but not installed. Please install packages manually or run as root."
|
||||||
fi
|
fi
|
||||||
# Validate sudo access
|
# Validate sudo access
|
||||||
if ! sudo -v 2>/dev/null; then
|
# When piped (curl | bash), stdin is the pipe — redirect from /dev/tty so sudo can prompt
|
||||||
die "Failed to obtain sudo privileges."
|
if [[ -e /dev/tty ]]; then
|
||||||
|
if ! sudo -v 2>/dev/null < /dev/tty; then
|
||||||
|
die "Failed to obtain sudo privileges."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if ! sudo -v 2>/dev/null; then
|
||||||
|
die "Failed to obtain sudo privileges. Try running the script directly instead of piping."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +379,12 @@ ensure_homebrew() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
info "Installing Homebrew first..."
|
info "Installing Homebrew first..."
|
||||||
/bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
# When piped (curl | bash), stdin is the pipe — Homebrew needs TTY for sudo password prompt
|
||||||
|
if [[ -e /dev/tty ]]; then
|
||||||
|
/bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" < /dev/tty
|
||||||
|
else
|
||||||
|
NONINTERACTIVE=1 /bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||||
|
fi
|
||||||
|
|
||||||
# Add Homebrew to PATH for Apple Silicon
|
# Add Homebrew to PATH for Apple Silicon
|
||||||
if [[ -f /opt/homebrew/bin/brew ]]; then
|
if [[ -f /opt/homebrew/bin/brew ]]; then
|
||||||
@@ -787,9 +799,84 @@ setup_sc_alias() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Systemd Service Setup (Linux only)
|
# Service Setup (Linux systemd / macOS launchd)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
setup_launchd_service() {
|
||||||
|
local plist_label="com.codeman.web"
|
||||||
|
local agent_dir="$HOME/Library/LaunchAgents"
|
||||||
|
local agent_plist="$agent_dir/$plist_label.plist"
|
||||||
|
local daemon_plist="/Library/LaunchDaemons/$plist_label.plist"
|
||||||
|
|
||||||
|
info "Setting up macOS LaunchAgent..."
|
||||||
|
|
||||||
|
# Remove any existing LaunchDaemon (system-level) to prevent duplicates.
|
||||||
|
# We standardize on LaunchAgent (user-level) — it doesn't require sudo,
|
||||||
|
# inherits the user's environment, and is the correct choice for user apps.
|
||||||
|
if [[ -f "$daemon_plist" ]]; then
|
||||||
|
warn "Found system-level LaunchDaemon at $daemon_plist — removing to prevent duplicate"
|
||||||
|
sudo launchctl unload "$daemon_plist" 2>/dev/null || true
|
||||||
|
sudo rm -f "$daemon_plist"
|
||||||
|
success "Removed duplicate LaunchDaemon"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Unload existing agent before overwriting
|
||||||
|
if [[ -f "$agent_plist" ]]; then
|
||||||
|
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$agent_dir"
|
||||||
|
|
||||||
|
# Build PATH: ensure /opt/homebrew/bin (Apple Silicon) and ~/.local/bin are included
|
||||||
|
local svc_path="/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||||
|
|
||||||
|
# Find node binary path
|
||||||
|
local node_path
|
||||||
|
node_path=$(command -v node)
|
||||||
|
|
||||||
|
cat > "$agent_plist" << EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>$plist_label</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>$node_path</string>
|
||||||
|
<string>$INSTALL_DIR/dist/index.js</string>
|
||||||
|
<string>web</string>
|
||||||
|
</array>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>$svc_path</string>
|
||||||
|
<key>HOME</key>
|
||||||
|
<string>$HOME</string>
|
||||||
|
<key>LANG</key>
|
||||||
|
<string>en_US.UTF-8</string>
|
||||||
|
</dict>
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>$HOME</string>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>ThrottleInterval</key>
|
||||||
|
<integer>10</integer>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/tmp/codeman.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/tmp/codeman.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
launchctl load "$agent_plist" 2>/dev/null || true
|
||||||
|
|
||||||
|
success "LaunchAgent installed and started"
|
||||||
|
}
|
||||||
|
|
||||||
setup_systemd_service() {
|
setup_systemd_service() {
|
||||||
local service_dir="$HOME/.config/systemd/user"
|
local service_dir="$HOME/.config/systemd/user"
|
||||||
local service_file="$service_dir/codeman-web.service"
|
local service_file="$service_dir/codeman-web.service"
|
||||||
@@ -1139,17 +1226,25 @@ main() {
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
local launch_choice=""
|
local launch_choice=""
|
||||||
local has_systemd=false
|
local has_service=false
|
||||||
|
local service_type=""
|
||||||
|
|
||||||
if [[ "$os" == "linux" ]] && [[ "$SKIP_SYSTEMD" != "1" ]] && command -v systemctl &>/dev/null; then
|
if [[ "$os" == "linux" ]] && [[ "$SKIP_SYSTEMD" != "1" ]] && command -v systemctl &>/dev/null; then
|
||||||
has_systemd=true
|
has_service=true
|
||||||
|
service_type="systemd"
|
||||||
|
elif [[ "$os" == "macos" ]] && [[ "$SKIP_SYSTEMD" != "1" ]]; then
|
||||||
|
has_service=true
|
||||||
|
service_type="launchd"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$has_systemd" == "true" ]]; then
|
if [[ "$has_service" == "true" ]]; then
|
||||||
|
local service_label="systemd service"
|
||||||
|
[[ "$service_type" == "launchd" ]] && service_label="LaunchAgent"
|
||||||
|
|
||||||
echo -e " ${BOLD}How would you like to run Codeman?${NC}"
|
echo -e " ${BOLD}How would you like to run Codeman?${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${CYAN}1)${NC} Run now in this terminal"
|
echo -e " ${CYAN}1)${NC} Run now in this terminal"
|
||||||
echo -e " ${CYAN}2)${NC} Install as systemd service (auto-start on boot)"
|
echo -e " ${CYAN}2)${NC} Install as $service_label (auto-start on boot)"
|
||||||
echo -e " ${CYAN}3)${NC} Don't start — I'll run it later"
|
echo -e " ${CYAN}3)${NC} Don't start — I'll run it later"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
@@ -1166,7 +1261,7 @@ main() {
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# macOS or no systemd — only offer run now or skip
|
# No service manager available — only offer run now or skip
|
||||||
echo -e " ${BOLD}Would you like to start Codeman now?${NC}"
|
echo -e " ${BOLD}Would you like to start Codeman now?${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${CYAN}1)${NC} Run now in this terminal"
|
echo -e " ${CYAN}1)${NC} Run now in this terminal"
|
||||||
@@ -1192,12 +1287,16 @@ main() {
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Handle systemd setup
|
# Handle service setup
|
||||||
if [[ "$launch_choice" == "2" ]]; then
|
if [[ "$launch_choice" == "2" ]]; then
|
||||||
setup_systemd_service
|
if [[ "$service_type" == "launchd" ]]; then
|
||||||
|
setup_launchd_service
|
||||||
|
else
|
||||||
|
setup_systemd_service
|
||||||
|
fi
|
||||||
|
|
||||||
# Offer tunnel service if cloudflared is available
|
# Offer tunnel service if cloudflared is available (Linux only — systemd tunnel service)
|
||||||
if check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
|
if [[ "$service_type" == "systemd" ]] && check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then
|
if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then
|
||||||
setup_tunnel_service
|
setup_tunnel_service
|
||||||
@@ -1212,10 +1311,16 @@ main() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo -e " ${BOLD}Manage the service:${NC}"
|
echo -e " ${BOLD}Manage the service:${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${CYAN}systemctl --user stop codeman-web${NC} # Stop"
|
if [[ "$service_type" == "launchd" ]]; then
|
||||||
echo -e " ${CYAN}systemctl --user restart codeman-web${NC} # Restart"
|
echo -e " ${CYAN}launchctl unload ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Stop"
|
||||||
echo -e " ${CYAN}systemctl --user status codeman-web${NC} # Check status"
|
echo -e " ${CYAN}launchctl load ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Start"
|
||||||
echo -e " ${CYAN}journalctl --user -u codeman-web -f${NC} # View logs"
|
echo -e " ${CYAN}tail -f /tmp/codeman.log${NC} # View logs"
|
||||||
|
else
|
||||||
|
echo -e " ${CYAN}systemctl --user stop codeman-web${NC} # Stop"
|
||||||
|
echo -e " ${CYAN}systemctl --user restart codeman-web${NC} # Restart"
|
||||||
|
echo -e " ${CYAN}systemctl --user status codeman-web${NC} # Check status"
|
||||||
|
echo -e " ${CYAN}journalctl --user -u codeman-web -f${NC} # View logs"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1289,11 +1394,17 @@ update() {
|
|||||||
success "Updated to $(node -e "console.log(require('./package.json').version)")"
|
success "Updated to $(node -e "console.log(require('./package.json').version)")"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Auto-restart systemd service if it's running, otherwise tell the user
|
# Auto-restart service if running, otherwise tell the user
|
||||||
if systemctl --user is-active codeman-web.service &>/dev/null; then
|
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||||
|
if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then
|
||||||
info "Restarting codeman-web service..."
|
info "Restarting codeman-web service..."
|
||||||
systemctl --user restart codeman-web.service
|
systemctl --user restart codeman-web.service
|
||||||
success "codeman-web service restarted"
|
success "codeman-web service restarted"
|
||||||
|
elif [[ -f "$agent_plist" ]]; then
|
||||||
|
info "Restarting LaunchAgent..."
|
||||||
|
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||||
|
launchctl load "$agent_plist" 2>/dev/null || true
|
||||||
|
success "LaunchAgent restarted"
|
||||||
else
|
else
|
||||||
echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
|
echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
|
||||||
echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}"
|
echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}"
|
||||||
@@ -1306,9 +1417,9 @@ uninstall() {
|
|||||||
info "Uninstalling Codeman..."
|
info "Uninstalling Codeman..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Stop and remove systemd services
|
# Stop and remove systemd services (Linux)
|
||||||
for svc in codeman-web codeman-tunnel; do
|
for svc in codeman-web codeman-tunnel; do
|
||||||
if systemctl --user is-active "${svc}.service" &>/dev/null; then
|
if systemctl --user is-active "${svc}.service" &>/dev/null 2>&1; then
|
||||||
info "Stopping ${svc} service..."
|
info "Stopping ${svc} service..."
|
||||||
systemctl --user stop "${svc}.service"
|
systemctl --user stop "${svc}.service"
|
||||||
fi
|
fi
|
||||||
@@ -1324,6 +1435,20 @@ uninstall() {
|
|||||||
done
|
done
|
||||||
systemctl --user daemon-reload 2>/dev/null || true
|
systemctl --user daemon-reload 2>/dev/null || true
|
||||||
|
|
||||||
|
# Stop and remove launchd services (macOS)
|
||||||
|
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||||
|
local daemon_plist="/Library/LaunchDaemons/com.codeman.web.plist"
|
||||||
|
if [[ -f "$agent_plist" ]]; then
|
||||||
|
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||||
|
rm -f "$agent_plist"
|
||||||
|
success "Removed LaunchAgent"
|
||||||
|
fi
|
||||||
|
if [[ -f "$daemon_plist" ]]; then
|
||||||
|
sudo launchctl unload "$daemon_plist" 2>/dev/null || true
|
||||||
|
sudo rm -f "$daemon_plist"
|
||||||
|
success "Removed LaunchDaemon"
|
||||||
|
fi
|
||||||
|
|
||||||
# Remove symlinks
|
# Remove symlinks
|
||||||
local symlink_dir="$HOME/.local/bin"
|
local symlink_dir="$HOME/.local/bin"
|
||||||
if [[ -L "$symlink_dir/codeman" ]]; then
|
if [[ -L "$symlink_dir/codeman" ]]; then
|
||||||
|
|||||||
@@ -1,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",
|
||||||
|
|||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 _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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
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 |