Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea1c2ee4ec | ||
|
|
b4a808adcf | ||
|
|
f3cbe9bca6 | ||
|
|
a11bcb0029 | ||
|
|
47fd9a922f | ||
|
|
14f7d8298d | ||
|
|
d32f4debb2 | ||
|
|
3cb7b510f8 | ||
|
|
6a12a72c9c | ||
|
|
f1a126efeb | ||
|
|
12fd780af8 | ||
|
|
fd74a42933 | ||
|
|
7101e64800 | ||
|
|
1b10d9b733 | ||
|
|
5078f5251d | ||
|
|
196af8fba7 | ||
|
|
a9b22b86a4 | ||
|
|
28cace5858 | ||
|
|
0a594b61bd | ||
|
|
89d787a949 | ||
|
|
bd9797b68c | ||
|
|
0e6cd94312 | ||
|
|
24a6f1cac8 | ||
|
|
8e679a280b | ||
|
|
c642689bbd | ||
|
|
28a6247c27 | ||
|
|
0ceb455c4b | ||
|
|
e51117dfa9 | ||
|
|
13d41cf7c7 | ||
|
|
2c7557d002 | ||
|
|
53b473708f | ||
|
|
2cba393ae5 | ||
|
|
64b8ea30b2 | ||
|
|
5743af3339 | ||
|
|
2011bd8d89 | ||
|
|
b76724690d | ||
|
|
f277f9664c | ||
|
|
cd49171bbc | ||
|
|
0f57342b10 | ||
|
|
e1f0ac993a | ||
|
|
a84ef52992 | ||
|
|
692c894760 | ||
|
|
ad0acb6d58 | ||
|
|
d866c8f30e | ||
|
|
28537de39d | ||
|
|
ba09184efa |
@@ -48,7 +48,12 @@ Thumbs.db
|
||||
# Generated output
|
||||
out/
|
||||
screenshots-echo-diag/
|
||||
tools/remotion/out/
|
||||
scripts/remotion/out/
|
||||
|
||||
# Artifacts that should not be tracked
|
||||
test-results/
|
||||
tmp/
|
||||
public
|
||||
|
||||
# Claude Code plan tracking
|
||||
plan.json
|
||||
|
||||
@@ -6,4 +6,4 @@ src/web/public/app.js
|
||||
src/web/public/styles.css
|
||||
src/web/public/mobile.css
|
||||
src/web/public/index.html
|
||||
tools/
|
||||
scripts/remotion/
|
||||
|
||||
@@ -1,5 +1,86 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.5.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Community contributions and security hardening:
|
||||
- Mobile response viewer: native-scroll panel for reading full Claude responses with markdown rendering via marked.js (PR #62)
|
||||
- PWA support: service worker caching, web app manifest, and Android home screen install (PR #59)
|
||||
- Named Cloudflare tunnel support (PR #58)
|
||||
- Markdown rendering for response viewer with HTML sanitization (XSS prevention) — strips dangerous elements, event handlers, and javascript: URIs
|
||||
- Service worker switched from stale-while-revalidate to network-first caching so deploys take effect immediately
|
||||
- Content-Disposition filename sanitization to prevent header injection in file downloads
|
||||
- Expose session.muxName public getter, replace unsafe `as any` cast in session-routes
|
||||
- Static import for execFile in session-routes
|
||||
- Keyboard shortcut updates: Alt+1-9 tab switching, Shift+Enter newline
|
||||
- Repo restructure for cleaner GitHub landing page
|
||||
- Mobile logo, expandable history, session resume fixes
|
||||
|
||||
## 0.5.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix: allow bracket characters in model validation regex so models like opus[1m] (1M context window) are accepted instead of silently dropped. Quote the model flag value in tmux spawn commands to prevent bash glob expansion of bracket patterns.
|
||||
|
||||
docs: update macOS launchd instructions to use `launchctl bootstrap` instead of deprecated `load`. Clean up README install and service sections.
|
||||
|
||||
## 0.5.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Mobile keyboard accessory bar: add configurable "Extended Keyboard Bar" setting (Settings > Display > Input) that toggles between simple mode (up/down arrows, /init, /clear, /compact, paste, dismiss) and extended mode (adds left/right arrows, Tab, Shift+Tab, Ctrl+O, Alt+Enter, Esc). Default is simple mode. Setting is device-specific (not synced to server).
|
||||
|
||||
Restyle dismiss button: muted steel-blue tone, fills remaining bar space via flex, larger tap target. Arrow buttons now blue.
|
||||
|
||||
Fix paste overlay visibility on mobile: dialog repositioned to top of screen (15vh from top) so the virtual keyboard doesn't cover it. Textarea enlarged for better usability.
|
||||
|
||||
(Also includes all v0.5.8 changes: case reorder/delete, XSS sanitization, auto-attach PTY on restart, mobile keyboard buttons, macOS installer fixes, terminal flicker fix, state store collision fix.)
|
||||
|
||||
## 0.5.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Case management: add Manage tab with reorder (up/down arrows) and delete for cases; linked cases are unlinked (folder preserved), CASES_DIR cases are permanently deleted. New endpoints: DELETE /api/cases/:name, PUT /api/cases/order. SSE events: case:deleted, case:order-changed.
|
||||
|
||||
Security: sanitize case names from filesystem with /^[a-zA-Z0-9_-]+$/ regex before returning from GET /api/cases to prevent XSS via maliciously-named directories reaching frontend inline onclick handlers.
|
||||
|
||||
Auto-attach PTY: server now calls startInteractive() for recovered tmux sessions during startup so all sessions resume capturing output immediately after deploy, instead of waiting for client selection. Frontend auto-attach condition relaxed from (pid===null && status==='idle') to (pid===null && !\_ended).
|
||||
|
||||
Mobile keyboard accessory: add Shift+Tab, Tab, Esc, Alt+Enter, Left/Right arrow, and Ctrl+O buttons.
|
||||
|
||||
Terminal: fix flicker regression by moving viewport clear inside dimension guard.
|
||||
|
||||
State store: fix temp file collisions on concurrent writes.
|
||||
|
||||
macOS: fix installer failures when piped via curl | bash, add HTML cache support, launchd service template, and trust dialog handling.
|
||||
|
||||
Housekeeping: remove accidentally committed dist/state-store.js build artifact.
|
||||
|
||||
## 0.5.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- feat: support "Default (CLI default)" option for model selection. Adds a new empty-value option to the model dropdown that defers to the CLI's own default model instead of forcing a specific model. Ensures empty defaultModel values are treated as undefined when passed to session creation and Ralph loop start, preventing empty strings from being sent as model flags.
|
||||
|
||||
## 0.5.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix: default new sessions to opus[1m] (1M context window) instead of plain opus (200k context)
|
||||
|
||||
## 0.5.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add 1M Opus context quick setting — per-case and global toggle that writes `model: "opus[1m]"` to `.claude/settings.local.json` when creating new sessions. Fix mobile layout: banners (respawn, timer, orchestrator) between header and main content now visible by switching from margin-top on `.main` to padding-top on `.app`. Add tablet-optimized respawn banner styles and mobile phone banner refinements.
|
||||
|
||||
## 0.5.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix terminal flicker regression — re-add server-side DEC 2026 synchronized output wrapping around batched terminal data. Ink spinner frames (cursor-up + redraw cycles) do not emit their own DEC 2026 markers, so without the server wrapper each partial cursor update rendered individually causing visible flicker. Also: extract SSE stream management, session listener wiring, and respawn event wiring from server.ts into dedicated modules; deduplicate error message extraction across 7 files with shared getErrorMessage() helper; update SSE event count in CLAUDE.md (106 → 117).
|
||||
|
||||
## 0.5.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -52,7 +52,7 @@ When user says "COM":
|
||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
||||
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
|
||||
**Version**: 0.5.3 (must match `package.json`)
|
||||
**Version**: 0.5.11 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -80,7 +80,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, `format:check` on push to master/main and on PRs (Node 22). Tests excluded (they spawn tmux).
|
||||
|
||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `tools/**`, `remotion/**`.
|
||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
@@ -110,7 +110,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
|
||||
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (14 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~2.6K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~2.8K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
|
||||
| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
|
||||
@@ -136,7 +136,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`.
|
||||
|
||||
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `agent-teams/`.
|
||||
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
|
||||
|
||||
**Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`.
|
||||
|
||||
@@ -150,7 +150,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+Enter (quick start), Ctrl+W (kill), Ctrl+Tab (next), Ctrl+K (kill all), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl/Cmd +/- (font).
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+W (kill), Ctrl+Tab (next), Alt+1-9 (switch tab), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl/Cmd +/- (font).
|
||||
|
||||
### Security
|
||||
|
||||
@@ -167,7 +167,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
### SSE Event Registry
|
||||
|
||||
~106 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
|
||||
~117 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
|
||||
|
||||
### API Routes
|
||||
|
||||
@@ -204,7 +204,7 @@ npx vitest run -t "pattern" # By name (SAFE)
|
||||
|
||||
**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests.
|
||||
|
||||
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject()` in `test/routes/`. **Mobile tests**: Playwright suite in `mobile-test/` (135 device profiles).
|
||||
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject()` in `test/routes/`. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles).
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -226,7 +226,7 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina
|
||||
|
||||
## References
|
||||
|
||||
Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`, `orchestrator-loop-architecture.md`, `browser-testing-guide.md`. Agent Teams: `agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`.
|
||||
Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`, `orchestrator-loop-architecture.md`, `browser-testing-guide.md`. Agent Teams: `docs/agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`.
|
||||
|
||||
## Scripts
|
||||
|
||||
|
||||
@@ -30,24 +30,6 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | b
|
||||
|
||||
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it.
|
||||
|
||||
**Install from a fork or specific branch:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/<user>/Codeman/<branch>/install.sh | \
|
||||
CODEMAN_REPO_URL=https://github.com/<user>/Codeman.git \
|
||||
CODEMAN_BRANCH=<branch> bash
|
||||
```
|
||||
|
||||
The installer supports these environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CODEMAN_REPO_URL` | upstream Codeman | Custom git repository URL |
|
||||
| `CODEMAN_BRANCH` | `master` | Git branch to install |
|
||||
| `CODEMAN_INSTALL_DIR` | `~/.codeman/app` | Custom install directory |
|
||||
| `CODEMAN_SKIP_SYSTEMD` | `0` | Skip systemd service setup prompt |
|
||||
| `CODEMAN_NODE_VERSION` | `22` | Node.js major version to install |
|
||||
| `CODEMAN_NONINTERACTIVE` | `0` | Skip all prompts (for CI/automation) |
|
||||
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai) (or both). After install:
|
||||
|
||||
```bash
|
||||
@@ -60,12 +42,53 @@ codeman web
|
||||
|
||||
**Linux (systemd):**
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user && printf '[Unit]\nDescription=Codeman Web Server\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=%s %s/dist/index.js web\nRestart=always\nRestartSec=10\n\n[Install]\nWantedBy=default.target\n' "$(which node)" "$HOME/.codeman/app" > ~/.config/systemd/user/codeman-web.service && systemctl --user daemon-reload && systemctl --user enable --now codeman-web && loginctl enable-linger $USER
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cat > ~/.config/systemd/user/codeman-web.service << EOF
|
||||
[Unit]
|
||||
Description=Codeman Web Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=$(which node) $HOME/.codeman/app/dist/index.js web
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now codeman-web
|
||||
loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
**macOS (launchd):**
|
||||
```bash
|
||||
mkdir -p ~/Library/LaunchAgents && printf '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>com.codeman.web</string><key>ProgramArguments</key><array><string>%s</string><string>%s/dist/index.js</string><string>web</string></array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>StandardOutPath</key><string>/tmp/codeman.log</string><key>StandardErrorPath</key><string>/tmp/codeman.log</string></dict></plist>\n' "$(which node)" "$HOME/.codeman/app" > ~/Library/LaunchAgents/com.codeman.web.plist && launchctl load ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
mkdir -p ~/Library/LaunchAgents
|
||||
cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.codeman.web</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$(which node)</string>
|
||||
<string>$HOME/.codeman/app/dist/index.js</string>
|
||||
<string>web</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/codeman.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/codeman.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
@@ -22,8 +22,7 @@ export default tseslint.config(
|
||||
'src/web/public/vendor/**',
|
||||
'src/web/public/app.js',
|
||||
'scripts/**/*.mjs',
|
||||
'tools/**',
|
||||
'remotion/**',
|
||||
'scripts/remotion/**',
|
||||
],
|
||||
}
|
||||
);
|
||||
@@ -1,7 +1,11 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
root,
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
@@ -7,7 +7,7 @@
|
||||
# Environment variables:
|
||||
# CODEMAN_NONINTERACTIVE=1 - Skip all prompts (for CI/automation)
|
||||
# CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app)
|
||||
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd service setup prompt
|
||||
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd/launchd service setup prompt
|
||||
# CODEMAN_NODE_VERSION - Node.js major version to install (default: 22)
|
||||
# CODEMAN_REPO_URL - Custom git repository URL (default: upstream Codeman)
|
||||
# CODEMAN_BRANCH - Git branch to install (default: master)
|
||||
@@ -353,8 +353,15 @@ ensure_sudo() {
|
||||
die "sudo is required but not installed. Please install packages manually or run as root."
|
||||
fi
|
||||
# Validate sudo access
|
||||
if ! sudo -v 2>/dev/null; then
|
||||
die "Failed to obtain sudo privileges."
|
||||
# When piped (curl | bash), stdin is the pipe — redirect from /dev/tty so sudo can prompt
|
||||
if [[ -e /dev/tty ]]; then
|
||||
if ! sudo -v 2>/dev/null < /dev/tty; then
|
||||
die "Failed to obtain sudo privileges."
|
||||
fi
|
||||
else
|
||||
if ! sudo -v 2>/dev/null; then
|
||||
die "Failed to obtain sudo privileges. Try running the script directly instead of piping."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -372,7 +379,12 @@ ensure_homebrew() {
|
||||
fi
|
||||
|
||||
info "Installing Homebrew first..."
|
||||
/bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
# When piped (curl | bash), stdin is the pipe — Homebrew needs TTY for sudo password prompt
|
||||
if [[ -e /dev/tty ]]; then
|
||||
/bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" < /dev/tty
|
||||
else
|
||||
NONINTERACTIVE=1 /bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
fi
|
||||
|
||||
# Add Homebrew to PATH for Apple Silicon
|
||||
if [[ -f /opt/homebrew/bin/brew ]]; then
|
||||
@@ -787,9 +799,84 @@ setup_sc_alias() {
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Systemd Service Setup (Linux only)
|
||||
# Service Setup (Linux systemd / macOS launchd)
|
||||
# ============================================================================
|
||||
|
||||
setup_launchd_service() {
|
||||
local plist_label="com.codeman.web"
|
||||
local agent_dir="$HOME/Library/LaunchAgents"
|
||||
local agent_plist="$agent_dir/$plist_label.plist"
|
||||
local daemon_plist="/Library/LaunchDaemons/$plist_label.plist"
|
||||
|
||||
info "Setting up macOS LaunchAgent..."
|
||||
|
||||
# Remove any existing LaunchDaemon (system-level) to prevent duplicates.
|
||||
# We standardize on LaunchAgent (user-level) — it doesn't require sudo,
|
||||
# inherits the user's environment, and is the correct choice for user apps.
|
||||
if [[ -f "$daemon_plist" ]]; then
|
||||
warn "Found system-level LaunchDaemon at $daemon_plist — removing to prevent duplicate"
|
||||
sudo launchctl unload "$daemon_plist" 2>/dev/null || true
|
||||
sudo rm -f "$daemon_plist"
|
||||
success "Removed duplicate LaunchDaemon"
|
||||
fi
|
||||
|
||||
# Unload existing agent before overwriting
|
||||
if [[ -f "$agent_plist" ]]; then
|
||||
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
mkdir -p "$agent_dir"
|
||||
|
||||
# Build PATH: ensure /opt/homebrew/bin (Apple Silicon) and ~/.local/bin are included
|
||||
local svc_path="/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
|
||||
# Find node binary path
|
||||
local node_path
|
||||
node_path=$(command -v node)
|
||||
|
||||
cat > "$agent_plist" << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$plist_label</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$node_path</string>
|
||||
<string>$INSTALL_DIR/dist/index.js</string>
|
||||
<string>web</string>
|
||||
</array>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>$svc_path</string>
|
||||
<key>HOME</key>
|
||||
<string>$HOME</string>
|
||||
<key>LANG</key>
|
||||
<string>en_US.UTF-8</string>
|
||||
</dict>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>$HOME</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/codeman.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/codeman.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
launchctl load "$agent_plist" 2>/dev/null || true
|
||||
|
||||
success "LaunchAgent installed and started"
|
||||
}
|
||||
|
||||
setup_systemd_service() {
|
||||
local service_dir="$HOME/.config/systemd/user"
|
||||
local service_file="$service_dir/codeman-web.service"
|
||||
@@ -1139,17 +1226,25 @@ main() {
|
||||
echo ""
|
||||
|
||||
local launch_choice=""
|
||||
local has_systemd=false
|
||||
local has_service=false
|
||||
local service_type=""
|
||||
|
||||
if [[ "$os" == "linux" ]] && [[ "$SKIP_SYSTEMD" != "1" ]] && command -v systemctl &>/dev/null; then
|
||||
has_systemd=true
|
||||
has_service=true
|
||||
service_type="systemd"
|
||||
elif [[ "$os" == "macos" ]] && [[ "$SKIP_SYSTEMD" != "1" ]]; then
|
||||
has_service=true
|
||||
service_type="launchd"
|
||||
fi
|
||||
|
||||
if [[ "$has_systemd" == "true" ]]; then
|
||||
if [[ "$has_service" == "true" ]]; then
|
||||
local service_label="systemd service"
|
||||
[[ "$service_type" == "launchd" ]] && service_label="LaunchAgent"
|
||||
|
||||
echo -e " ${BOLD}How would you like to run Codeman?${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}1)${NC} Run now in this terminal"
|
||||
echo -e " ${CYAN}2)${NC} Install as systemd service (auto-start on boot)"
|
||||
echo -e " ${CYAN}2)${NC} Install as $service_label (auto-start on boot)"
|
||||
echo -e " ${CYAN}3)${NC} Don't start — I'll run it later"
|
||||
echo ""
|
||||
|
||||
@@ -1166,7 +1261,7 @@ main() {
|
||||
done
|
||||
fi
|
||||
else
|
||||
# macOS or no systemd — only offer run now or skip
|
||||
# No service manager available — only offer run now or skip
|
||||
echo -e " ${BOLD}Would you like to start Codeman now?${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}1)${NC} Run now in this terminal"
|
||||
@@ -1192,12 +1287,16 @@ main() {
|
||||
|
||||
echo ""
|
||||
|
||||
# Handle systemd setup
|
||||
# Handle service setup
|
||||
if [[ "$launch_choice" == "2" ]]; then
|
||||
setup_systemd_service
|
||||
if [[ "$service_type" == "launchd" ]]; then
|
||||
setup_launchd_service
|
||||
else
|
||||
setup_systemd_service
|
||||
fi
|
||||
|
||||
# Offer tunnel service if cloudflared is available
|
||||
if check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
|
||||
# Offer tunnel service if cloudflared is available (Linux only — systemd tunnel service)
|
||||
if [[ "$service_type" == "systemd" ]] && check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
|
||||
echo ""
|
||||
if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then
|
||||
setup_tunnel_service
|
||||
@@ -1212,10 +1311,16 @@ main() {
|
||||
echo ""
|
||||
echo -e " ${BOLD}Manage the service:${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}systemctl --user stop codeman-web${NC} # Stop"
|
||||
echo -e " ${CYAN}systemctl --user restart codeman-web${NC} # Restart"
|
||||
echo -e " ${CYAN}systemctl --user status codeman-web${NC} # Check status"
|
||||
echo -e " ${CYAN}journalctl --user -u codeman-web -f${NC} # View logs"
|
||||
if [[ "$service_type" == "launchd" ]]; then
|
||||
echo -e " ${CYAN}launchctl unload ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Stop"
|
||||
echo -e " ${CYAN}launchctl load ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Start"
|
||||
echo -e " ${CYAN}tail -f /tmp/codeman.log${NC} # View logs"
|
||||
else
|
||||
echo -e " ${CYAN}systemctl --user stop codeman-web${NC} # Stop"
|
||||
echo -e " ${CYAN}systemctl --user restart codeman-web${NC} # Restart"
|
||||
echo -e " ${CYAN}systemctl --user status codeman-web${NC} # Check status"
|
||||
echo -e " ${CYAN}journalctl --user -u codeman-web -f${NC} # View logs"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
@@ -1289,11 +1394,17 @@ update() {
|
||||
success "Updated to $(node -e "console.log(require('./package.json').version)")"
|
||||
echo ""
|
||||
|
||||
# Auto-restart systemd service if it's running, otherwise tell the user
|
||||
if systemctl --user is-active codeman-web.service &>/dev/null; then
|
||||
# Auto-restart service if running, otherwise tell the user
|
||||
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||
if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then
|
||||
info "Restarting codeman-web service..."
|
||||
systemctl --user restart codeman-web.service
|
||||
success "codeman-web service restarted"
|
||||
elif [[ -f "$agent_plist" ]]; then
|
||||
info "Restarting LaunchAgent..."
|
||||
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||
launchctl load "$agent_plist" 2>/dev/null || true
|
||||
success "LaunchAgent restarted"
|
||||
else
|
||||
echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
|
||||
echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}"
|
||||
@@ -1306,9 +1417,9 @@ uninstall() {
|
||||
info "Uninstalling Codeman..."
|
||||
echo ""
|
||||
|
||||
# Stop and remove systemd services
|
||||
# Stop and remove systemd services (Linux)
|
||||
for svc in codeman-web codeman-tunnel; do
|
||||
if systemctl --user is-active "${svc}.service" &>/dev/null; then
|
||||
if systemctl --user is-active "${svc}.service" &>/dev/null 2>&1; then
|
||||
info "Stopping ${svc} service..."
|
||||
systemctl --user stop "${svc}.service"
|
||||
fi
|
||||
@@ -1324,6 +1435,20 @@ uninstall() {
|
||||
done
|
||||
systemctl --user daemon-reload 2>/dev/null || true
|
||||
|
||||
# Stop and remove launchd services (macOS)
|
||||
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||
local daemon_plist="/Library/LaunchDaemons/com.codeman.web.plist"
|
||||
if [[ -f "$agent_plist" ]]; then
|
||||
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||
rm -f "$agent_plist"
|
||||
success "Removed LaunchAgent"
|
||||
fi
|
||||
if [[ -f "$daemon_plist" ]]; then
|
||||
sudo launchctl unload "$daemon_plist" 2>/dev/null || true
|
||||
sudo rm -f "$daemon_plist"
|
||||
success "Removed LaunchDaemon"
|
||||
fi
|
||||
|
||||
# Remove symlinks
|
||||
local symlink_dir="$HOME/.local/bin"
|
||||
if [[ -L "$symlink_dir/codeman" ]]; then
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.5.3",
|
||||
"version": "0.5.11",
|
||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -15,12 +15,12 @@
|
||||
"dev": "tsx src/index.ts web",
|
||||
"web": "node dist/index.js web",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test": "vitest run --config config/vitest.config.ts",
|
||||
"test:watch": "vitest --config config/vitest.config.ts",
|
||||
"test:coverage": "vitest run --config config/vitest.config.ts --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
||||
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
||||
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
|
||||
"format": "prettier --write 'src/**/*.ts'",
|
||||
"format:check": "prettier --check 'src/**/*.ts'",
|
||||
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
|
||||
|
||||
@@ -428,7 +428,7 @@ const SUBAGENT_ACTIVITY = {
|
||||
'agent-002': [
|
||||
{ type: 'tool', tool: 'Glob', input: { pattern: 'test/**/*.test.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
{ type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
{ type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/vitest.config.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
{ type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/config/vitest.config.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
{ type: 'message', role: 'assistant', text: 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...', timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* Usage: node scripts/capture-video-screenshots.mjs
|
||||
* Port: 3198 (static file server)
|
||||
* Output: remotion/public/ (6 PNGs)
|
||||
* Output: scripts/scripts/remotion/public/ (6 PNGs)
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
@@ -21,7 +21,7 @@ import { fileURLToPath } from 'url';
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PROJECT_ROOT = join(__dirname, '..');
|
||||
const PUBLIC_DIR = join(PROJECT_ROOT, 'src', 'web', 'public');
|
||||
const OUTPUT_DIR = join(PROJECT_ROOT, 'remotion', 'public');
|
||||
const OUTPUT_DIR = join(PROJECT_ROOT, 'scripts', 'remotion', 'public');
|
||||
const PORT = 3198;
|
||||
|
||||
const DESKTOP_VIEWPORT = { width: 1920, height: 1080 };
|
||||
@@ -516,7 +516,7 @@ async function captureDesktopWelcome(browser) {
|
||||
path: join(OUTPUT_DIR, 'desktop-welcome.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/desktop-welcome.png');
|
||||
console.log(' Saved: scripts/remotion/public/desktop-welcome.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -545,7 +545,7 @@ async function captureDesktopClaude(browser) {
|
||||
path: join(OUTPUT_DIR, 'desktop-claude.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/desktop-claude.png');
|
||||
console.log(' Saved: scripts/remotion/public/desktop-claude.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -575,7 +575,7 @@ async function captureDesktopBothClaude(browser) {
|
||||
path: join(OUTPUT_DIR, 'desktop-both-claude.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/desktop-both-claude.png');
|
||||
console.log(' Saved: scripts/remotion/public/desktop-both-claude.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -605,7 +605,7 @@ async function captureDesktopBothOpencode(browser) {
|
||||
path: join(OUTPUT_DIR, 'desktop-both-opencode.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/desktop-both-opencode.png');
|
||||
console.log(' Saved: scripts/remotion/public/desktop-both-opencode.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -634,7 +634,7 @@ async function captureMobileClaude(browser) {
|
||||
path: join(OUTPUT_DIR, 'mobile-claude.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/mobile-claude.png');
|
||||
console.log(' Saved: scripts/remotion/public/mobile-claude.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -663,7 +663,7 @@ async function captureMobileOpencode(browser) {
|
||||
path: join(OUTPUT_DIR, 'mobile-opencode.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/mobile-opencode.png');
|
||||
console.log(' Saved: scripts/remotion/public/mobile-opencode.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -706,12 +706,12 @@ async function main() {
|
||||
console.log('All 6 screenshots captured!');
|
||||
console.log('='.repeat(60));
|
||||
console.log('\nOutput files:');
|
||||
console.log(' remotion/public/desktop-welcome.png');
|
||||
console.log(' remotion/public/desktop-claude.png');
|
||||
console.log(' remotion/public/desktop-both-claude.png');
|
||||
console.log(' remotion/public/desktop-both-opencode.png');
|
||||
console.log(' remotion/public/mobile-claude.png');
|
||||
console.log(' remotion/public/mobile-opencode.png');
|
||||
console.log(' scripts/remotion/public/desktop-welcome.png');
|
||||
console.log(' scripts/remotion/public/desktop-claude.png');
|
||||
console.log(' scripts/remotion/public/desktop-both-claude.png');
|
||||
console.log(' scripts/remotion/public/desktop-both-opencode.png');
|
||||
console.log(' scripts/remotion/public/mobile-claude.png');
|
||||
console.log(' scripts/remotion/public/mobile-opencode.png');
|
||||
} catch (err) {
|
||||
console.error('\nFatal error:', err.message);
|
||||
console.error(err.stack);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Codeman Cloudflare Named Tunnel
|
||||
After=network-online.target codeman-web.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/cloudflared tunnel --config %h/.cloudflared/codeman.yml run codeman
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
KillMode=process
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=codeman-tunnel-named
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
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 |
@@ -1,45 +1,209 @@
|
||||
#!/usr/bin/env bash
|
||||
# Quick Cloudflare Tunnel for Codeman
|
||||
# Usage: ./scripts/tunnel.sh [start|stop|status|url]
|
||||
# Cloudflare Tunnel manager for Codeman
|
||||
# Usage: ./scripts/tunnel.sh [quick|named] [start|stop|status|url]
|
||||
#
|
||||
# Modes:
|
||||
# quick — Quick tunnel with random trycloudflare.com URL (default)
|
||||
# named — Named tunnel on a fixed hostname (requires setup, see below)
|
||||
#
|
||||
# Environment variables:
|
||||
# CLOUDFLARED_TUNNEL_NAME — tunnel name (default: codeman)
|
||||
# CLOUDFLARED_TUNNEL_ID — tunnel UUID (from: cloudflared tunnel list)
|
||||
# CODEMAN_TUNNEL_HOSTNAME — public hostname (e.g. codeman.example.com)
|
||||
#
|
||||
# First-time named tunnel setup:
|
||||
# cloudflared tunnel login
|
||||
# cloudflared tunnel create <tunnel-name>
|
||||
# cloudflared tunnel route dns <tunnel-name> <hostname>
|
||||
# ./scripts/tunnel.sh named setup # writes ~/.cloudflared/<tunnel-name>.yml
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE="codeman-tunnel"
|
||||
QUICK_SERVICE="codeman-tunnel"
|
||||
NAMED_SERVICE="codeman-tunnel-named"
|
||||
TUNNEL_NAME="${CLOUDFLARED_TUNNEL_NAME:-codeman}"
|
||||
TUNNEL_HOSTNAME="${CODEMAN_TUNNEL_HOSTNAME:-codeman.example.com}"
|
||||
CODEMAN_PORT="3000"
|
||||
LOG_FILE="$HOME/.codeman/tunnel.log"
|
||||
|
||||
case "${1:-start}" in
|
||||
start)
|
||||
if ! systemctl --user is-active "$SERVICE" &>/dev/null; then
|
||||
# Install service if not already
|
||||
if ! systemctl --user cat "$SERVICE" &>/dev/null 2>&1; then
|
||||
cp "$(dirname "$0")/codeman-tunnel.service" "$HOME/.config/systemd/user/"
|
||||
systemctl --user daemon-reload
|
||||
fi
|
||||
systemctl --user start "$SERVICE"
|
||||
echo "Tunnel starting... waiting for URL"
|
||||
sleep 6
|
||||
fi
|
||||
# Extract the tunnel URL from journal
|
||||
URL=$(grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$HOME/.codeman/tunnel.log" 2>/dev/null | tail -1)
|
||||
if [ -n "$URL" ]; then
|
||||
echo "$URL"
|
||||
else
|
||||
echo "URL not ready yet, try: $0 url"
|
||||
fi
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_require_cloudflared() {
|
||||
if ! command -v cloudflared &>/dev/null; then
|
||||
echo "Error: cloudflared not found. Install with: yay -S cloudflared" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
_cloudflared_bin() {
|
||||
command -v cloudflared
|
||||
}
|
||||
|
||||
_install_service() {
|
||||
local svc_file="$1"
|
||||
local svc_name="$2"
|
||||
if ! systemctl --user cat "$svc_name" &>/dev/null 2>&1; then
|
||||
cp "$(dirname "$0")/$svc_file" "$HOME/.config/systemd/user/"
|
||||
systemctl --user daemon-reload
|
||||
echo "Service $svc_name installed."
|
||||
fi
|
||||
}
|
||||
|
||||
_install_named_service() {
|
||||
if ! systemctl --user cat "$NAMED_SERVICE" &>/dev/null 2>&1; then
|
||||
# Generate service file with the configured tunnel name
|
||||
sed "s/codeman\.yml/$TUNNEL_NAME.yml/g; s/run codeman/run $TUNNEL_NAME/g" \
|
||||
"$(dirname "$0")/codeman-tunnel-named.service" \
|
||||
> "$HOME/.config/systemd/user/codeman-tunnel-named.service"
|
||||
systemctl --user daemon-reload
|
||||
echo "Service $NAMED_SERVICE installed (tunnel: $TUNNEL_NAME)."
|
||||
fi
|
||||
}
|
||||
|
||||
# ── named tunnel setup ───────────────────────────────────────────────────────
|
||||
|
||||
_named_setup() {
|
||||
_require_cloudflared
|
||||
|
||||
local creds_dir="$HOME/.cloudflared"
|
||||
local config_file="$creds_dir/$TUNNEL_NAME.yml"
|
||||
# Replace with your tunnel ID (from: cloudflared tunnel list)
|
||||
local tunnel_id="${CLOUDFLARED_TUNNEL_ID:-YOUR_TUNNEL_ID_HERE}"
|
||||
local creds_file="$creds_dir/$tunnel_id.json"
|
||||
|
||||
if [ ! -f "$creds_file" ]; then
|
||||
echo "Credentials not found: $creds_file"
|
||||
echo "Run: cloudflared tunnel create $TUNNEL_NAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$config_file" <<EOF
|
||||
tunnel: $tunnel_id
|
||||
credentials-file: $creds_file
|
||||
|
||||
ingress:
|
||||
- hostname: $TUNNEL_HOSTNAME
|
||||
service: http://localhost:$CODEMAN_PORT
|
||||
- service: http_status:404
|
||||
EOF
|
||||
|
||||
echo "Config written to $config_file"
|
||||
echo "Tunnel ID: $tunnel_id"
|
||||
echo "Hostname: $TUNNEL_HOSTNAME"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Add Cloudflare Access policy for $TUNNEL_HOSTNAME (Zero Trust dashboard)"
|
||||
echo " 2. ./scripts/tunnel.sh named start"
|
||||
}
|
||||
|
||||
# ── quick mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
_quick_start() {
|
||||
if ! systemctl --user is-active "$QUICK_SERVICE" &>/dev/null; then
|
||||
_install_service "codeman-tunnel.service" "$QUICK_SERVICE"
|
||||
systemctl --user start "$QUICK_SERVICE"
|
||||
echo "Quick tunnel starting... waiting for URL"
|
||||
sleep 6
|
||||
fi
|
||||
local url
|
||||
url=$(grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" 2>/dev/null | tail -1)
|
||||
if [ -n "$url" ]; then
|
||||
echo "$url"
|
||||
else
|
||||
echo "URL not ready yet, try: $0 quick url"
|
||||
fi
|
||||
}
|
||||
|
||||
_quick_stop() {
|
||||
systemctl --user stop "$QUICK_SERVICE"
|
||||
echo "Quick tunnel stopped"
|
||||
}
|
||||
|
||||
_quick_status() {
|
||||
systemctl --user status "$QUICK_SERVICE" --no-pager 2>&1 | head -10
|
||||
echo ""
|
||||
echo "URL:"
|
||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" 2>/dev/null | tail -1
|
||||
}
|
||||
|
||||
_quick_url() {
|
||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" 2>/dev/null | tail -1
|
||||
}
|
||||
|
||||
# ── named mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
_named_start() {
|
||||
_require_cloudflared
|
||||
if [ ! -f "$HOME/.cloudflared/$TUNNEL_NAME.yml" ]; then
|
||||
echo "Config not found. Run: $0 named setup"
|
||||
exit 1
|
||||
fi
|
||||
if ! systemctl --user is-active "$NAMED_SERVICE" &>/dev/null; then
|
||||
_install_named_service
|
||||
systemctl --user start "$NAMED_SERVICE"
|
||||
echo "Named tunnel starting..."
|
||||
sleep 3
|
||||
fi
|
||||
echo "https://$TUNNEL_HOSTNAME"
|
||||
}
|
||||
|
||||
_named_stop() {
|
||||
systemctl --user stop "$NAMED_SERVICE"
|
||||
echo "Named tunnel stopped"
|
||||
}
|
||||
|
||||
_named_status() {
|
||||
systemctl --user status "$NAMED_SERVICE" --no-pager 2>&1 | head -10
|
||||
echo ""
|
||||
echo "URL: https://$TUNNEL_HOSTNAME"
|
||||
}
|
||||
|
||||
_named_enable() {
|
||||
_install_named_service
|
||||
systemctl --user enable "$NAMED_SERVICE"
|
||||
echo "Named tunnel enabled at boot."
|
||||
}
|
||||
|
||||
_named_disable() {
|
||||
systemctl --user disable "$NAMED_SERVICE"
|
||||
echo "Named tunnel disabled."
|
||||
}
|
||||
|
||||
# ── dispatch ─────────────────────────────────────────────────────────────────
|
||||
|
||||
MODE="${1:-quick}"
|
||||
CMD="${2:-start}"
|
||||
|
||||
case "$MODE" in
|
||||
quick)
|
||||
case "$CMD" in
|
||||
start) _quick_start ;;
|
||||
stop) _quick_stop ;;
|
||||
status) _quick_status ;;
|
||||
url) _quick_url ;;
|
||||
*) echo "Usage: $0 quick [start|stop|status|url]"; exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
stop)
|
||||
systemctl --user stop "$SERVICE"
|
||||
echo "Tunnel stopped"
|
||||
;;
|
||||
status)
|
||||
systemctl --user status "$SERVICE" --no-pager 2>&1 | head -10
|
||||
echo ""
|
||||
echo "URL:"
|
||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$HOME/.codeman/tunnel.log" 2>/dev/null | tail -1
|
||||
;;
|
||||
url)
|
||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$HOME/.codeman/tunnel.log" 2>/dev/null | tail -1
|
||||
named)
|
||||
case "$CMD" in
|
||||
start) _named_start ;;
|
||||
stop) _named_stop ;;
|
||||
status) _named_status ;;
|
||||
url) echo "https://$TUNNEL_HOSTNAME" ;;
|
||||
setup) _named_setup ;;
|
||||
enable) _named_enable ;;
|
||||
disable) _named_disable ;;
|
||||
*) echo "Usage: $0 named [start|stop|status|url|setup|enable|disable]"; exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
# backward compat: no mode prefix → quick tunnel
|
||||
start) _quick_start ;;
|
||||
stop) _quick_stop ;;
|
||||
status) _quick_status ;;
|
||||
url) _quick_url ;;
|
||||
*)
|
||||
echo "Usage: $0 [start|stop|status|url]"
|
||||
echo "Usage: $0 [quick|named] [start|stop|status|url]"
|
||||
echo " $0 named setup # first-time named tunnel configuration"
|
||||
echo " $0 named enable # start at boot"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -30,6 +30,7 @@ import { join } from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { getAugmentedPath, ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js';
|
||||
import { AI_CHECK_MAX_BACKOFF_MS } from './config/ai-defaults.js';
|
||||
import { getErrorMessage } from './types.js';
|
||||
|
||||
// ========== Security Validation ==========
|
||||
|
||||
@@ -293,7 +294,7 @@ export abstract class AiCheckerBase<
|
||||
this.emit('checkCompleted', result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
const errorMsg = getErrorMessage(err);
|
||||
this.handleError(errorMsg);
|
||||
const result = this.createErrorResult(errorMsg, Date.now() - this.checkStartTime);
|
||||
this.emit('checkFailed', errorMsg);
|
||||
@@ -412,9 +413,7 @@ export abstract class AiCheckerBase<
|
||||
});
|
||||
muxProcess.unref();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to spawn ${this.checkDescription} tmux session: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
throw new Error(`Failed to spawn ${this.checkDescription} tmux session: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
// Poll the temp file for completion
|
||||
|
||||
@@ -470,120 +470,91 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
* Process a single pre-stripped line of terminal output.
|
||||
*/
|
||||
private processCleanLine(cleanLine: string): void {
|
||||
// Check for tool start
|
||||
if (this._handleToolStart(cleanLine)) return;
|
||||
if (this._handleToolCompletion(cleanLine)) return;
|
||||
if (this._handleTextCommand(cleanLine)) return;
|
||||
this._handleLogFileMention(cleanLine);
|
||||
}
|
||||
|
||||
private _handleToolStart(cleanLine: string): boolean {
|
||||
const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
|
||||
if (startMatch) {
|
||||
const command = startMatch[1];
|
||||
const timeout = startMatch[2]?.trim();
|
||||
if (!startMatch) return false;
|
||||
|
||||
// Check if this is a file-viewing command
|
||||
if (this.isFileViewerCommand(command)) {
|
||||
const filePaths = this.extractFilePaths(command);
|
||||
const command = startMatch[1];
|
||||
const timeout = startMatch[2]?.trim();
|
||||
|
||||
// Skip if any file path is already tracked (cross-pattern dedup)
|
||||
if (filePaths.some((fp) => this.isFilePathTracked(fp))) {
|
||||
return;
|
||||
}
|
||||
if (!this.isFileViewerCommand(command)) return true;
|
||||
|
||||
if (filePaths.length > 0) {
|
||||
const tool: ActiveBashTool = {
|
||||
id: uuidv4(),
|
||||
command,
|
||||
filePaths,
|
||||
timeout,
|
||||
startedAt: Date.now(),
|
||||
status: 'running',
|
||||
sessionId: this._sessionId,
|
||||
};
|
||||
const filePaths = this.extractFilePaths(command);
|
||||
|
||||
// Enforce max tools limit
|
||||
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
|
||||
// Remove oldest tool (O(n) min-scan instead of O(n log n) sort)
|
||||
let oldestKey: string | undefined;
|
||||
let oldestTime = Infinity;
|
||||
for (const [key, entry] of this._activeTools) {
|
||||
if (entry.startedAt < oldestTime) {
|
||||
oldestTime = entry.startedAt;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
if (oldestKey) {
|
||||
this._activeTools.delete(oldestKey);
|
||||
}
|
||||
// Skip if any file path is already tracked (cross-pattern dedup)
|
||||
if (filePaths.some((fp) => this.isFilePathTracked(fp))) return true;
|
||||
|
||||
if (filePaths.length > 0) {
|
||||
const tool = this._createActiveTool(command, filePaths, 'running', timeout);
|
||||
|
||||
// Enforce max tools limit
|
||||
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
|
||||
// Remove oldest tool (O(n) min-scan instead of O(n log n) sort)
|
||||
let oldestKey: string | undefined;
|
||||
let oldestTime = Infinity;
|
||||
for (const [key, entry] of this._activeTools) {
|
||||
if (entry.startedAt < oldestTime) {
|
||||
oldestTime = entry.startedAt;
|
||||
oldestKey = key;
|
||||
}
|
||||
|
||||
this._activeTools.set(tool.id, tool);
|
||||
this._lastToolId = tool.id;
|
||||
|
||||
this.emit('toolStart', tool);
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for tool completion
|
||||
if (TOOL_COMPLETION_PATTERN.test(cleanLine) && this._lastToolId) {
|
||||
const tool = this._activeTools.get(this._lastToolId);
|
||||
if (tool && tool.status === 'running') {
|
||||
tool.status = 'completed';
|
||||
this.emit('toolEnd', tool);
|
||||
this.scheduleUpdate();
|
||||
|
||||
// Remove completed tool after a short delay to allow UI to show completion
|
||||
this.cleanup.setTimeout(
|
||||
() => {
|
||||
if (this._destroyed) return;
|
||||
this._activeTools.delete(tool.id);
|
||||
this.scheduleUpdate();
|
||||
},
|
||||
2000,
|
||||
{ description: 'auto-remove completed tool' }
|
||||
);
|
||||
}
|
||||
this._lastToolId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: Check for command suggestions in plain text (e.g., "tail -f /tmp/file.log")
|
||||
const textCmdMatch = cleanLine.match(TEXT_COMMAND_PATTERN);
|
||||
if (textCmdMatch) {
|
||||
const filePath = textCmdMatch[2];
|
||||
|
||||
// Create a suggestion tool (marked as 'suggestion' status)
|
||||
const tool: ActiveBashTool = {
|
||||
id: uuidv4(),
|
||||
command: cleanLine.trim(),
|
||||
filePaths: [filePath],
|
||||
timeout: undefined,
|
||||
startedAt: Date.now(),
|
||||
status: 'running', // Shows as clickable
|
||||
sessionId: this._sessionId,
|
||||
};
|
||||
|
||||
// Don't add if file path already tracked (cross-pattern dedup)
|
||||
if (this.isFilePathTracked(filePath)) {
|
||||
return;
|
||||
if (oldestKey) {
|
||||
this._activeTools.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
this._activeTools.set(tool.id, tool);
|
||||
this._lastToolId = tool.id;
|
||||
|
||||
this.emit('toolStart', tool);
|
||||
this.scheduleUpdate();
|
||||
|
||||
// Auto-remove suggestions after 30 seconds
|
||||
this.cleanup.setTimeout(
|
||||
() => {
|
||||
if (this._destroyed) return;
|
||||
this._activeTools.delete(tool.id);
|
||||
this.scheduleUpdate();
|
||||
},
|
||||
30000,
|
||||
{ description: 'auto-remove suggestion tool' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Last fallback: Check for log file paths mentioned anywhere in the line
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleToolCompletion(cleanLine: string): boolean {
|
||||
if (!TOOL_COMPLETION_PATTERN.test(cleanLine) || !this._lastToolId) return false;
|
||||
|
||||
const tool = this._activeTools.get(this._lastToolId);
|
||||
if (tool && tool.status === 'running') {
|
||||
tool.status = 'completed';
|
||||
this.emit('toolEnd', tool);
|
||||
this.scheduleUpdate();
|
||||
|
||||
this._scheduleAutoRemove(tool.id, 2000, 'auto-remove completed tool');
|
||||
}
|
||||
this._lastToolId = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleTextCommand(cleanLine: string): boolean {
|
||||
const textCmdMatch = cleanLine.match(TEXT_COMMAND_PATTERN);
|
||||
if (!textCmdMatch) return false;
|
||||
|
||||
const filePath = textCmdMatch[2];
|
||||
|
||||
// Don't add if file path already tracked (cross-pattern dedup)
|
||||
if (this.isFilePathTracked(filePath)) return true;
|
||||
|
||||
const tool = this._createActiveTool(cleanLine.trim(), [filePath], 'running');
|
||||
|
||||
this._activeTools.set(tool.id, tool);
|
||||
this.emit('toolStart', tool);
|
||||
this.scheduleUpdate();
|
||||
|
||||
// Auto-remove suggestions after 30 seconds
|
||||
this._scheduleAutoRemove(tool.id, 30000, 'auto-remove suggestion tool');
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleLogFileMention(cleanLine: string): void {
|
||||
LOG_FILE_MENTION_PATTERN.lastIndex = 0;
|
||||
let logMatch;
|
||||
while ((logMatch = LOG_FILE_MENTION_PATTERN.exec(cleanLine)) !== null) {
|
||||
@@ -595,33 +566,46 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
// Skip if file path already tracked (cross-pattern dedup)
|
||||
if (this.isFilePathTracked(filePath)) continue;
|
||||
|
||||
const tool: ActiveBashTool = {
|
||||
id: uuidv4(),
|
||||
command: `View: ${filePath}`,
|
||||
filePaths: [filePath],
|
||||
timeout: undefined,
|
||||
startedAt: Date.now(),
|
||||
status: 'running',
|
||||
sessionId: this._sessionId,
|
||||
};
|
||||
const tool = this._createActiveTool(`View: ${filePath}`, [filePath], 'running');
|
||||
|
||||
this._activeTools.set(tool.id, tool);
|
||||
this.emit('toolStart', tool);
|
||||
this.scheduleUpdate();
|
||||
|
||||
// Auto-remove after 60 seconds
|
||||
this.cleanup.setTimeout(
|
||||
() => {
|
||||
if (this._destroyed) return;
|
||||
this._activeTools.delete(tool.id);
|
||||
this.scheduleUpdate();
|
||||
},
|
||||
60000,
|
||||
{ description: 'auto-remove log file tool' }
|
||||
);
|
||||
this._scheduleAutoRemove(tool.id, 60000, 'auto-remove log file tool');
|
||||
}
|
||||
}
|
||||
|
||||
private _createActiveTool(
|
||||
command: string,
|
||||
filePaths: string[],
|
||||
status: ActiveBashTool['status'],
|
||||
timeout?: string
|
||||
): ActiveBashTool {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
command,
|
||||
filePaths,
|
||||
timeout,
|
||||
startedAt: Date.now(),
|
||||
status,
|
||||
sessionId: this._sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
private _scheduleAutoRemove(toolId: string, delayMs: number, description: string): void {
|
||||
this.cleanup.setTimeout(
|
||||
() => {
|
||||
if (this._destroyed) return;
|
||||
this._activeTools.delete(toolId);
|
||||
this.scheduleUpdate();
|
||||
},
|
||||
delayMs,
|
||||
{ description }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command is a file-viewing command worth tracking.
|
||||
*/
|
||||
|
||||
@@ -16,6 +16,7 @@ import { existsSync, statSync, realpathSync } from 'node:fs';
|
||||
import { resolve, relative, isAbsolute } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { getErrorMessage } from './types.js';
|
||||
import { CLEANUP_CHECK_INTERVAL_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
|
||||
|
||||
// ========== Configuration Constants ==========
|
||||
@@ -172,10 +173,7 @@ export class FileStreamManager extends EventEmitter {
|
||||
}
|
||||
} catch (err) {
|
||||
const errorCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : 'UNKNOWN';
|
||||
console.warn(
|
||||
`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`,
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
console.warn(`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`, getErrorMessage(err));
|
||||
return { success: false, error: 'File not found or not accessible' };
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,34 @@ export async function updateCaseEnvVars(casePath: string, envVars: Record<string
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the `model` field in .claude/settings.local.json for the given case path.
|
||||
* Pass a non-empty string to set, or empty/null to remove.
|
||||
*/
|
||||
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
|
||||
const claudeDir = join(casePath, '.claude');
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
let existing: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
|
||||
if (model) {
|
||||
existing.model = model;
|
||||
} else {
|
||||
delete existing.model;
|
||||
}
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes hooks config to .claude/settings.local.json in the given case path.
|
||||
* Merges with existing file content, only touching the `hooks` key.
|
||||
|
||||
@@ -499,27 +499,34 @@ export class OrchestratorLoop extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private handleTaskCompleted(queueTaskId: string): void {
|
||||
private _finalizeTask(queueTaskId: string, status: 'completed' | 'failed', error?: string): OrchestratorTask | null {
|
||||
const orchTask = this.findOrchestratorTaskByQueueId(queueTaskId);
|
||||
if (!orchTask) return null;
|
||||
|
||||
orchTask.status = status;
|
||||
if (status === 'completed') {
|
||||
orchTask.completedAt = Date.now();
|
||||
this.stats.totalTasksCompleted++;
|
||||
} else {
|
||||
orchTask.error = error ?? null;
|
||||
this.stats.totalTasksFailed++;
|
||||
}
|
||||
this.persist();
|
||||
return orchTask;
|
||||
}
|
||||
|
||||
private handleTaskCompleted(queueTaskId: string): void {
|
||||
const orchTask = this._finalizeTask(queueTaskId, 'completed');
|
||||
if (!orchTask) return;
|
||||
|
||||
orchTask.status = 'completed';
|
||||
orchTask.completedAt = Date.now();
|
||||
this.stats.totalTasksCompleted++;
|
||||
this.persist();
|
||||
this.emit('taskCompleted', orchTask);
|
||||
|
||||
this.checkPhaseCompletion();
|
||||
}
|
||||
|
||||
private handleTaskFailed(queueTaskId: string, error: string): void {
|
||||
const orchTask = this.findOrchestratorTaskByQueueId(queueTaskId);
|
||||
const orchTask = this._finalizeTask(queueTaskId, 'failed', error);
|
||||
if (!orchTask) return;
|
||||
|
||||
orchTask.status = 'failed';
|
||||
orchTask.error = error;
|
||||
this.stats.totalTasksFailed++;
|
||||
this.persist();
|
||||
this.emit('taskFailed', orchTask, error);
|
||||
|
||||
// Check if we should retry the task or fail the phase
|
||||
@@ -553,19 +560,20 @@ export class OrchestratorLoop extends EventEmitter {
|
||||
}, this.config.phaseTimeoutMs);
|
||||
}
|
||||
|
||||
private _clearTimer(
|
||||
timerKey: 'phasePollTimer' | 'phaseTimeoutTimer' | 'postPhaseTimer',
|
||||
clearFn: typeof clearInterval | typeof clearTimeout
|
||||
): void {
|
||||
if (this[timerKey]) {
|
||||
clearFn(this[timerKey]);
|
||||
this[timerKey] = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearPhasePoll(): void {
|
||||
if (this.phasePollTimer) {
|
||||
clearInterval(this.phasePollTimer);
|
||||
this.phasePollTimer = null;
|
||||
}
|
||||
if (this.phaseTimeoutTimer) {
|
||||
clearTimeout(this.phaseTimeoutTimer);
|
||||
this.phaseTimeoutTimer = null;
|
||||
}
|
||||
if (this.postPhaseTimer) {
|
||||
clearTimeout(this.postPhaseTimer);
|
||||
this.postPhaseTimer = null;
|
||||
}
|
||||
this._clearTimer('phasePollTimer', clearInterval);
|
||||
this._clearTimer('phaseTimeoutTimer', clearTimeout);
|
||||
this._clearTimer('postPhaseTimer', clearTimeout);
|
||||
}
|
||||
|
||||
private pollPhaseStatus(phase: OrchestratorPhase): void {
|
||||
|
||||
@@ -20,7 +20,13 @@
|
||||
*/
|
||||
|
||||
import type { Session } from './session.js';
|
||||
import type { OrchestratorPhase, OrchestratorConfig, VerificationResult, VerificationCheck } from './types.js';
|
||||
import {
|
||||
getErrorMessage,
|
||||
type OrchestratorPhase,
|
||||
type OrchestratorConfig,
|
||||
type VerificationResult,
|
||||
type VerificationCheck,
|
||||
} from './types.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Constants
|
||||
@@ -111,7 +117,7 @@ export class OrchestratorVerifier {
|
||||
type: 'test_command',
|
||||
description: `Run: ${command}`,
|
||||
passed: false,
|
||||
output: err instanceof Error ? err.message : String(err),
|
||||
output: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -164,7 +170,7 @@ export class OrchestratorVerifier {
|
||||
type: 'ai_review',
|
||||
description: `AI review of "${phase.name}"`,
|
||||
passed: false,
|
||||
output: `AI review timed out or failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
output: `AI review timed out or failed: ${getErrorMessage(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { TerminalMultiplexer } from './mux-interface.js';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
|
||||
import type { PlanItem } from './types.js';
|
||||
import { getErrorMessage, type PlanItem } from './types.js';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { PlanItem };
|
||||
@@ -231,6 +231,49 @@ export class PlanOrchestrator {
|
||||
return md;
|
||||
}
|
||||
|
||||
private _extractJsonFromResponse(response: string): string | null {
|
||||
let jsonMatch = response.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
|
||||
if (jsonMatch) {
|
||||
jsonMatch = [jsonMatch[1]]; // Use captured group (inside code block)
|
||||
} else {
|
||||
jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
}
|
||||
return jsonMatch ? jsonMatch[0] : null;
|
||||
}
|
||||
|
||||
private _emitAgentFailure(
|
||||
onSubagent: SubagentCallback | undefined,
|
||||
agentId: string,
|
||||
agentType: 'research' | 'planner',
|
||||
model: string,
|
||||
error: string,
|
||||
durationMs: number
|
||||
): void {
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType,
|
||||
model,
|
||||
status: 'failed',
|
||||
error,
|
||||
durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
private _formatResearchSection(
|
||||
parts: string[],
|
||||
title: string,
|
||||
items: unknown[],
|
||||
formatter: (item: unknown) => string[]
|
||||
): void {
|
||||
if (items.length === 0) return;
|
||||
parts.push(title);
|
||||
for (const item of items.slice(0, 5)) {
|
||||
parts.push(...formatter(item));
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
this.cancelled = true;
|
||||
// Stop all running sessions and await cleanup to prevent PTY process leaks
|
||||
@@ -312,7 +355,7 @@ export class PlanOrchestrator {
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
error: getErrorMessage(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -322,32 +365,23 @@ export class PlanOrchestrator {
|
||||
|
||||
const parts: string[] = ['## Research Context\n'];
|
||||
|
||||
if (research.findings.externalResources.length > 0) {
|
||||
parts.push('### External Resources');
|
||||
for (const r of research.findings.externalResources.slice(0, 5)) {
|
||||
parts.push(`- ${r.title}${r.url ? ` (${r.url})` : ''}`);
|
||||
if (r.keyInsights.length > 0) {
|
||||
parts.push(` Key insights: ${r.keyInsights.slice(0, 3).join(', ')}`);
|
||||
}
|
||||
this._formatResearchSection(parts, '### External Resources', research.findings.externalResources, (item) => {
|
||||
const r = item as ResearchResult['findings']['externalResources'][number];
|
||||
const lines = [`- ${r.title}${r.url ? ` (${r.url})` : ''}`];
|
||||
if (r.keyInsights.length > 0) {
|
||||
lines.push(` Key insights: ${r.keyInsights.slice(0, 3).join(', ')}`);
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
|
||||
if (research.findings.codebasePatterns.length > 0) {
|
||||
parts.push('### Existing Codebase Patterns');
|
||||
for (const p of research.findings.codebasePatterns.slice(0, 5)) {
|
||||
parts.push(`- ${p.pattern} at ${p.location}`);
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
this._formatResearchSection(parts, '### Existing Codebase Patterns', research.findings.codebasePatterns, (item) => {
|
||||
const p = item as ResearchResult['findings']['codebasePatterns'][number];
|
||||
return [`- ${p.pattern} at ${p.location}`];
|
||||
});
|
||||
|
||||
if (research.findings.technicalRecommendations.length > 0) {
|
||||
parts.push('### Recommendations');
|
||||
for (const r of research.findings.technicalRecommendations.slice(0, 5)) {
|
||||
parts.push(`- ${r}`);
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
this._formatResearchSection(parts, '### Recommendations', research.findings.technicalRecommendations, (item) => [
|
||||
`- ${item as string}`,
|
||||
]);
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
@@ -414,18 +448,20 @@ export class PlanOrchestrator {
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
// Extract JSON from response
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'research',
|
||||
model: this.researchModel,
|
||||
status: 'failed',
|
||||
error: 'No JSON found',
|
||||
durationMs,
|
||||
});
|
||||
console.log(
|
||||
`[PlanOrchestrator] Research response length: ${response.length}, first 500 chars:`,
|
||||
response.substring(0, 500)
|
||||
);
|
||||
|
||||
// Extract JSON from response — try multiple strategies
|
||||
const jsonStr = this._extractJsonFromResponse(response);
|
||||
|
||||
if (!jsonStr) {
|
||||
console.error(
|
||||
`[PlanOrchestrator] No JSON found in research response. Full response:`,
|
||||
response.substring(0, 2000)
|
||||
);
|
||||
this._emitAgentFailure(onSubagent, agentId, 'research', this.researchModel, 'No JSON found', durationMs);
|
||||
return {
|
||||
success: false,
|
||||
findings: {
|
||||
@@ -441,17 +477,9 @@ export class PlanOrchestrator {
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = tryParseJSON(jsonMatch[0]);
|
||||
const parsed = tryParseJSON(jsonStr);
|
||||
if (!parsed.success) {
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'research',
|
||||
model: this.researchModel,
|
||||
status: 'failed',
|
||||
error: parsed.error,
|
||||
durationMs,
|
||||
});
|
||||
this._emitAgentFailure(onSubagent, agentId, 'research', this.researchModel, parsed.error!, durationMs);
|
||||
return {
|
||||
success: false,
|
||||
findings: {
|
||||
@@ -495,16 +523,8 @@ export class PlanOrchestrator {
|
||||
return result;
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'research',
|
||||
model: this.researchModel,
|
||||
status: 'failed',
|
||||
error,
|
||||
durationMs,
|
||||
});
|
||||
const error = getErrorMessage(err);
|
||||
this._emitAgentFailure(onSubagent, agentId, 'research', this.researchModel, error, durationMs);
|
||||
return {
|
||||
success: false,
|
||||
findings: {
|
||||
@@ -587,32 +607,26 @@ export class PlanOrchestrator {
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
// Extract JSON from response
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'planner',
|
||||
model: this.plannerModel,
|
||||
status: 'failed',
|
||||
error: 'No JSON found',
|
||||
durationMs,
|
||||
});
|
||||
console.log(
|
||||
`[PlanOrchestrator] Planner response length: ${response.length}, first 500 chars:`,
|
||||
response.substring(0, 500)
|
||||
);
|
||||
|
||||
// Extract JSON from response — try multiple strategies
|
||||
const jsonStr = this._extractJsonFromResponse(response);
|
||||
|
||||
if (!jsonStr) {
|
||||
console.error(
|
||||
`[PlanOrchestrator] No JSON found in planner response. Full response:`,
|
||||
response.substring(0, 2000)
|
||||
);
|
||||
this._emitAgentFailure(onSubagent, agentId, 'planner', this.plannerModel, 'No JSON found', durationMs);
|
||||
return { success: false, error: 'No JSON in response' };
|
||||
}
|
||||
|
||||
const parsed = tryParseJSON(jsonMatch[0]);
|
||||
const parsed = tryParseJSON(jsonStr);
|
||||
if (!parsed.success) {
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'planner',
|
||||
model: this.plannerModel,
|
||||
status: 'failed',
|
||||
error: parsed.error,
|
||||
durationMs,
|
||||
});
|
||||
this._emitAgentFailure(onSubagent, agentId, 'planner', this.plannerModel, parsed.error!, durationMs);
|
||||
return { success: false, error: parsed.error };
|
||||
}
|
||||
|
||||
@@ -637,16 +651,8 @@ export class PlanOrchestrator {
|
||||
return { success: true, items, gaps, warnings };
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'planner',
|
||||
model: this.plannerModel,
|
||||
status: 'failed',
|
||||
error,
|
||||
durationMs,
|
||||
});
|
||||
const error = getErrorMessage(err);
|
||||
this._emitAgentFailure(onSubagent, agentId, 'planner', this.plannerModel, error, durationMs);
|
||||
return { success: false, error };
|
||||
} finally {
|
||||
// Always clean up session and progress interval — centralizing here
|
||||
|
||||
@@ -91,6 +91,67 @@ const COMPLETION_INDICATOR_PATTERNS = [
|
||||
/project\s+(?:is\s+)?(?:completed?|done|finished)/i,
|
||||
];
|
||||
|
||||
interface FieldParser<T> {
|
||||
pattern: RegExp;
|
||||
field: keyof RalphStatusBlock;
|
||||
validate: (value: string) => boolean;
|
||||
transform: (value: string) => T;
|
||||
errorMsg: (value: string) => string;
|
||||
}
|
||||
|
||||
const FIELD_PARSERS: FieldParser<RalphStatusValue | RalphTestsStatus | RalphWorkType | number | boolean | string>[] = [
|
||||
{
|
||||
pattern: RALPH_STATUS_FIELD_PATTERN,
|
||||
field: 'status',
|
||||
validate: (v) => ['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(v.toUpperCase()),
|
||||
transform: (v) => v.toUpperCase() as RalphStatusValue,
|
||||
errorMsg: (v) => `Invalid STATUS value: "${v}". Expected: IN_PROGRESS, COMPLETE, or BLOCKED`,
|
||||
},
|
||||
{
|
||||
pattern: RALPH_TASKS_COMPLETED_PATTERN,
|
||||
field: 'tasksCompletedThisLoop',
|
||||
validate: (v) => !Number.isNaN(parseInt(v, 10)) && parseInt(v, 10) >= 0,
|
||||
transform: (v) => parseInt(v, 10),
|
||||
errorMsg: (v) => `Invalid TASKS_COMPLETED_THIS_LOOP value: "${v}". Expected: non-negative integer`,
|
||||
},
|
||||
{
|
||||
pattern: RALPH_FILES_MODIFIED_PATTERN,
|
||||
field: 'filesModified',
|
||||
validate: (v) => !Number.isNaN(parseInt(v, 10)) && parseInt(v, 10) >= 0,
|
||||
transform: (v) => parseInt(v, 10),
|
||||
errorMsg: (v) => `Invalid FILES_MODIFIED value: "${v}". Expected: non-negative integer`,
|
||||
},
|
||||
{
|
||||
pattern: RALPH_TESTS_STATUS_PATTERN,
|
||||
field: 'testsStatus',
|
||||
validate: (v) => ['PASSING', 'FAILING', 'NOT_RUN'].includes(v.toUpperCase()),
|
||||
transform: (v) => v.toUpperCase() as RalphTestsStatus,
|
||||
errorMsg: (v) => `Invalid TESTS_STATUS value: "${v}". Expected: PASSING, FAILING, or NOT_RUN`,
|
||||
},
|
||||
{
|
||||
pattern: RALPH_WORK_TYPE_PATTERN,
|
||||
field: 'workType',
|
||||
validate: (v) => ['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(v.toUpperCase()),
|
||||
transform: (v) => v.toUpperCase() as RalphWorkType,
|
||||
errorMsg: (v) =>
|
||||
`Invalid WORK_TYPE value: "${v}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`,
|
||||
},
|
||||
{
|
||||
pattern: RALPH_EXIT_SIGNAL_PATTERN,
|
||||
field: 'exitSignal',
|
||||
validate: () => true,
|
||||
transform: (v) => v.toLowerCase() === 'true',
|
||||
errorMsg: () => '',
|
||||
},
|
||||
{
|
||||
pattern: RALPH_RECOMMENDATION_PATTERN,
|
||||
field: 'recommendation',
|
||||
validate: () => true,
|
||||
transform: (v) => v.trim(),
|
||||
errorMsg: () => '',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* RalphStatusParser - Parses RALPH_STATUS blocks and manages circuit breaker.
|
||||
*
|
||||
@@ -303,85 +364,21 @@ export class RalphStatusParser extends EventEmitter {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) continue;
|
||||
|
||||
// Track whether this line matched any known field
|
||||
let matched = false;
|
||||
|
||||
// STATUS field (required)
|
||||
const statusMatch = trimmedLine.match(RALPH_STATUS_FIELD_PATTERN);
|
||||
if (statusMatch) {
|
||||
const value = statusMatch[1].toUpperCase();
|
||||
if (['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(value)) {
|
||||
block.status = value as RalphStatusValue;
|
||||
} else {
|
||||
parseErrors.push(`Invalid STATUS value: "${value}". Expected: IN_PROGRESS, COMPLETE, or BLOCKED`);
|
||||
for (const parser of FIELD_PARSERS) {
|
||||
const match = trimmedLine.match(parser.pattern);
|
||||
if (match) {
|
||||
const rawValue = match[1];
|
||||
if (parser.validate(rawValue)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(block as any)[parser.field] = parser.transform(rawValue);
|
||||
} else {
|
||||
parseErrors.push(parser.errorMsg(rawValue));
|
||||
}
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// TASKS_COMPLETED_THIS_LOOP field
|
||||
const tasksMatch = trimmedLine.match(RALPH_TASKS_COMPLETED_PATTERN);
|
||||
if (tasksMatch) {
|
||||
const value = parseInt(tasksMatch[1], 10);
|
||||
if (!Number.isNaN(value) && value >= 0) {
|
||||
block.tasksCompletedThisLoop = value;
|
||||
} else {
|
||||
parseErrors.push(
|
||||
`Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer`
|
||||
);
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// FILES_MODIFIED field
|
||||
const filesMatch = trimmedLine.match(RALPH_FILES_MODIFIED_PATTERN);
|
||||
if (filesMatch) {
|
||||
const value = parseInt(filesMatch[1], 10);
|
||||
if (!Number.isNaN(value) && value >= 0) {
|
||||
block.filesModified = value;
|
||||
} else {
|
||||
parseErrors.push(`Invalid FILES_MODIFIED value: "${filesMatch[1]}". Expected: non-negative integer`);
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// TESTS_STATUS field
|
||||
const testsMatch = trimmedLine.match(RALPH_TESTS_STATUS_PATTERN);
|
||||
if (testsMatch) {
|
||||
const value = testsMatch[1].toUpperCase();
|
||||
if (['PASSING', 'FAILING', 'NOT_RUN'].includes(value)) {
|
||||
block.testsStatus = value as RalphTestsStatus;
|
||||
} else {
|
||||
parseErrors.push(`Invalid TESTS_STATUS value: "${value}". Expected: PASSING, FAILING, or NOT_RUN`);
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// WORK_TYPE field
|
||||
const workMatch = trimmedLine.match(RALPH_WORK_TYPE_PATTERN);
|
||||
if (workMatch) {
|
||||
const value = workMatch[1].toUpperCase();
|
||||
if (['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(value)) {
|
||||
block.workType = value as RalphWorkType;
|
||||
} else {
|
||||
parseErrors.push(
|
||||
`Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`
|
||||
);
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// EXIT_SIGNAL field
|
||||
const exitMatch = trimmedLine.match(RALPH_EXIT_SIGNAL_PATTERN);
|
||||
if (exitMatch) {
|
||||
block.exitSignal = exitMatch[1].toLowerCase() === 'true';
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// RECOMMENDATION field
|
||||
const recMatch = trimmedLine.match(RALPH_RECOMMENDATION_PATTERN);
|
||||
if (recMatch) {
|
||||
block.recommendation = recMatch[1].trim();
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// Track unknown fields for debugging (only if looks like a field)
|
||||
@@ -475,38 +472,9 @@ export class RalphStatusParser extends EventEmitter {
|
||||
const prevState = this._circuitBreaker.state;
|
||||
|
||||
if (hasProgress) {
|
||||
// Progress detected - reset counters, possibly close circuit
|
||||
this._circuitBreaker.consecutiveNoProgress = 0;
|
||||
this._circuitBreaker.consecutiveSameError = 0;
|
||||
this._circuitBreaker.lastProgressIteration = this._cycleCount;
|
||||
|
||||
if (this._circuitBreaker.state === 'HALF_OPEN') {
|
||||
this._circuitBreaker.state = 'CLOSED';
|
||||
this._circuitBreaker.reason = 'Progress detected, circuit closed';
|
||||
this._circuitBreaker.reasonCode = 'progress_detected';
|
||||
}
|
||||
this._handleProgressDetected();
|
||||
} else {
|
||||
// No progress
|
||||
this._circuitBreaker.consecutiveNoProgress++;
|
||||
|
||||
// State transitions based on consecutive no-progress
|
||||
if (this._circuitBreaker.state === 'CLOSED') {
|
||||
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
|
||||
this._circuitBreaker.state = 'OPEN';
|
||||
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
|
||||
this._circuitBreaker.reasonCode = 'no_progress_open';
|
||||
} else if (this._circuitBreaker.consecutiveNoProgress >= 2) {
|
||||
this._circuitBreaker.state = 'HALF_OPEN';
|
||||
this._circuitBreaker.reason = 'Warning: no progress detected';
|
||||
this._circuitBreaker.reasonCode = 'no_progress_warning';
|
||||
}
|
||||
} else if (this._circuitBreaker.state === 'HALF_OPEN') {
|
||||
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
|
||||
this._circuitBreaker.state = 'OPEN';
|
||||
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
|
||||
this._circuitBreaker.reasonCode = 'no_progress_open';
|
||||
}
|
||||
}
|
||||
this._handleNoProgress();
|
||||
}
|
||||
|
||||
// Track tests failure
|
||||
@@ -535,6 +503,40 @@ export class RalphStatusParser extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private _handleProgressDetected(): void {
|
||||
this._circuitBreaker.consecutiveNoProgress = 0;
|
||||
this._circuitBreaker.consecutiveSameError = 0;
|
||||
this._circuitBreaker.lastProgressIteration = this._cycleCount;
|
||||
|
||||
if (this._circuitBreaker.state === 'HALF_OPEN') {
|
||||
this._circuitBreaker.state = 'CLOSED';
|
||||
this._circuitBreaker.reason = 'Progress detected, circuit closed';
|
||||
this._circuitBreaker.reasonCode = 'progress_detected';
|
||||
}
|
||||
}
|
||||
|
||||
private _handleNoProgress(): void {
|
||||
this._circuitBreaker.consecutiveNoProgress++;
|
||||
|
||||
if (this._circuitBreaker.state === 'CLOSED') {
|
||||
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
|
||||
this._circuitBreaker.state = 'OPEN';
|
||||
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
|
||||
this._circuitBreaker.reasonCode = 'no_progress_open';
|
||||
} else if (this._circuitBreaker.consecutiveNoProgress >= 2) {
|
||||
this._circuitBreaker.state = 'HALF_OPEN';
|
||||
this._circuitBreaker.reason = 'Warning: no progress detected';
|
||||
this._circuitBreaker.reasonCode = 'no_progress_warning';
|
||||
}
|
||||
} else if (this._circuitBreaker.state === 'HALF_OPEN') {
|
||||
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
|
||||
this._circuitBreaker.state = 'OPEN';
|
||||
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
|
||||
this._circuitBreaker.reasonCode = 'no_progress_open';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check line for completion indicators (natural language patterns).
|
||||
* Used for dual-condition exit gate.
|
||||
|
||||
@@ -70,12 +70,13 @@ import {
|
||||
AI_PLAN_CHECK_TIMEOUT_MS,
|
||||
AI_PLAN_CHECK_COOLDOWN_MS,
|
||||
} from './config/ai-defaults.js';
|
||||
import type {
|
||||
RespawnCycleMetrics,
|
||||
RespawnAggregateMetrics,
|
||||
RalphLoopHealthScore,
|
||||
TimingHistory,
|
||||
CycleOutcome,
|
||||
import {
|
||||
getErrorMessage,
|
||||
type RespawnCycleMetrics,
|
||||
type RespawnAggregateMetrics,
|
||||
type RalphLoopHealthScore,
|
||||
type TimingHistory,
|
||||
type CycleOutcome,
|
||||
} from './types.js';
|
||||
|
||||
// ========== Constants ==========
|
||||
@@ -860,27 +861,29 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure timeouts are positive
|
||||
validatePositiveTimeout('idleTimeoutMs');
|
||||
validatePositiveTimeout('completionConfirmMs');
|
||||
validatePositiveTimeout('noOutputTimeoutMs');
|
||||
validatePositiveTimeout('autoAcceptDelayMs', true);
|
||||
validatePositiveTimeout('interStepDelayMs');
|
||||
const REQUIRED_TIMEOUT_FIELDS = [
|
||||
'idleTimeoutMs',
|
||||
'completionConfirmMs',
|
||||
'noOutputTimeoutMs',
|
||||
'interStepDelayMs',
|
||||
'aiIdleCheckTimeoutMs',
|
||||
'aiIdleCheckMaxContext',
|
||||
'aiPlanCheckTimeoutMs',
|
||||
'aiPlanCheckMaxContext',
|
||||
] as const;
|
||||
for (const field of REQUIRED_TIMEOUT_FIELDS) {
|
||||
validatePositiveTimeout(field);
|
||||
}
|
||||
|
||||
const ALLOW_ZERO_FIELDS = ['autoAcceptDelayMs', 'aiIdleCheckCooldownMs', 'aiPlanCheckCooldownMs'] as const;
|
||||
for (const field of ALLOW_ZERO_FIELDS) {
|
||||
validatePositiveTimeout(field, true);
|
||||
}
|
||||
|
||||
// Ensure completion confirm doesn't exceed no-output timeout
|
||||
if (c.completionConfirmMs > c.noOutputTimeoutMs) {
|
||||
c.completionConfirmMs = c.noOutputTimeoutMs;
|
||||
}
|
||||
|
||||
// Ensure AI check timeouts are positive
|
||||
validatePositiveTimeout('aiIdleCheckTimeoutMs');
|
||||
validatePositiveTimeout('aiIdleCheckCooldownMs', true);
|
||||
validatePositiveTimeout('aiIdleCheckMaxContext');
|
||||
|
||||
// Ensure plan check timeouts are positive
|
||||
validatePositiveTimeout('aiPlanCheckTimeoutMs');
|
||||
validatePositiveTimeout('aiPlanCheckCooldownMs', true);
|
||||
validatePositiveTimeout('aiPlanCheckMaxContext');
|
||||
}
|
||||
|
||||
/** Wire up AI checker events to controller events (removes existing listeners first to prevent duplicates) */
|
||||
@@ -1385,98 +1388,13 @@ export class RespawnController extends EventEmitter {
|
||||
this.lastTokenChangeTime = now;
|
||||
}
|
||||
|
||||
// Detect completion message FIRST (Layer 1) - PRIMARY DETECTION
|
||||
// Check this before working patterns because completion message indicates
|
||||
// the work is done, even if working patterns are still in the rolling window
|
||||
if (isCompletionMessage(data)) {
|
||||
// Clear the rolling window - completion marks a transition point
|
||||
this.clearWorkingPatternWindow();
|
||||
this.workingDetected = false;
|
||||
this.completionMessageTime = now;
|
||||
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
|
||||
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
|
||||
// Layer 1: Completion message (PRIMARY) — checked before working patterns
|
||||
if (this._detectCompletionMessage(data, now)) return;
|
||||
|
||||
// In watching state, start completion confirmation timer
|
||||
if (this._state === 'watching') {
|
||||
this.startCompletionConfirmTimer();
|
||||
return;
|
||||
}
|
||||
// Layer 4: Working patterns
|
||||
if (this._detectWorkingPattern(data, now)) return;
|
||||
|
||||
// In waiting states, also use confirmation timer (same detection logic)
|
||||
// This ensures we wait for Claude to finish before proceeding
|
||||
// Note: 'watching' is already handled above and returns early
|
||||
switch (this._state) {
|
||||
case 'waiting_update':
|
||||
this.startStepConfirmTimer('update');
|
||||
break;
|
||||
case 'waiting_clear':
|
||||
this.checkClearComplete(); // /clear is quick, no need to wait
|
||||
break;
|
||||
case 'waiting_init':
|
||||
this.startStepConfirmTimer('init');
|
||||
break;
|
||||
case 'waiting_kickstart':
|
||||
this.startStepConfirmTimer('kickstart');
|
||||
break;
|
||||
// Non-waiting states: completion message is ignored
|
||||
case 'confirming_idle':
|
||||
case 'ai_checking':
|
||||
case 'sending_update':
|
||||
case 'sending_clear':
|
||||
case 'sending_init':
|
||||
case 'monitoring_init':
|
||||
case 'sending_kickstart':
|
||||
case 'stopped':
|
||||
// Completion message during these states is ignored
|
||||
break;
|
||||
default:
|
||||
assertNever(this._state, `Unhandled RespawnState in completion detection: ${this._state}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect working patterns (Layer 4)
|
||||
const isWorking = this.checkWorkingPattern(data);
|
||||
if (isWorking) {
|
||||
this.workingDetected = true;
|
||||
this.promptDetected = false;
|
||||
this.elicitationDetected = false; // Clear on new work cycle
|
||||
this.resetHookState(); // Clear hook signals on new work
|
||||
this.lastWorkingPatternTime = now;
|
||||
|
||||
// Cancel hook confirmation timer if running
|
||||
this.cancelTrackedTimer('hook-confirm', 'working patterns detected');
|
||||
|
||||
// Cancel any pending completion confirmation
|
||||
this.cancelCompletionConfirm();
|
||||
|
||||
// Cancel any pending step confirmation (Claude is still working)
|
||||
this.cancelStepConfirm();
|
||||
|
||||
// If AI check is running, cancel it (Claude is working)
|
||||
if (this._state === 'ai_checking') {
|
||||
this.log('Working patterns detected during AI check, cancelling');
|
||||
this.aiChecker.cancel();
|
||||
this.setState('watching');
|
||||
}
|
||||
|
||||
// Cancel plan check if running (Claude started working)
|
||||
if (this.planChecker.status === 'checking') {
|
||||
this.log('Working patterns detected during plan check, cancelling');
|
||||
this.planChecker.cancel();
|
||||
}
|
||||
|
||||
// If we're monitoring init and work started, go to watching (no kickstart needed)
|
||||
if (this._state === 'monitoring_init') {
|
||||
this.log('/init triggered work, skipping kickstart');
|
||||
this.emit('stepCompleted', 'init');
|
||||
this.completeCycle();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// In confirming_idle or ai_checking state, substantial output cancels the flow.
|
||||
// This prevents false triggers when Claude pauses briefly mid-work.
|
||||
// Substantial output during confirming_idle/ai_checking cancels the flow
|
||||
if (this._state === 'confirming_idle' || this._state === 'ai_checking') {
|
||||
// Strip ANSI escape codes to check if there's real content
|
||||
ANSI_ESCAPE_PATTERN_SIMPLE.lastIndex = 0;
|
||||
@@ -1497,43 +1415,137 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallback: detect prompt characters (still useful for waiting_* states)
|
||||
const hasPrompt = PROMPT_PATTERNS.some((pattern) => data.includes(pattern));
|
||||
if (hasPrompt) {
|
||||
this.promptDetected = true;
|
||||
this.workingDetected = false;
|
||||
// Legacy fallback: prompt detection
|
||||
this._detectPrompt(data);
|
||||
}
|
||||
|
||||
// Handle legacy detection in waiting states - also use confirmation timers
|
||||
switch (this._state) {
|
||||
case 'waiting_update':
|
||||
this.startStepConfirmTimer('update');
|
||||
break;
|
||||
case 'waiting_clear':
|
||||
this.checkClearComplete(); // /clear is quick, no need to wait
|
||||
break;
|
||||
case 'waiting_init':
|
||||
this.startStepConfirmTimer('init');
|
||||
break;
|
||||
case 'monitoring_init':
|
||||
this.checkMonitoringInitIdle();
|
||||
break;
|
||||
case 'waiting_kickstart':
|
||||
this.startStepConfirmTimer('kickstart');
|
||||
break;
|
||||
// Non-waiting states: prompt detection is informational only
|
||||
case 'watching':
|
||||
case 'confirming_idle':
|
||||
case 'ai_checking':
|
||||
case 'sending_update':
|
||||
case 'sending_clear':
|
||||
case 'sending_init':
|
||||
case 'sending_kickstart':
|
||||
case 'stopped':
|
||||
// Prompt detection during these states doesn't trigger action
|
||||
break;
|
||||
default:
|
||||
assertNever(this._state, `Unhandled RespawnState in prompt detection: ${this._state}`);
|
||||
}
|
||||
private _detectCompletionMessage(data: string, now: number): boolean {
|
||||
if (!isCompletionMessage(data)) return false;
|
||||
|
||||
// Clear the rolling window - completion marks a transition point
|
||||
this.clearWorkingPatternWindow();
|
||||
this.workingDetected = false;
|
||||
this.completionMessageTime = now;
|
||||
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
|
||||
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
|
||||
|
||||
// In watching state, start completion confirmation timer
|
||||
if (this._state === 'watching') {
|
||||
this.startCompletionConfirmTimer();
|
||||
return true;
|
||||
}
|
||||
|
||||
// In waiting states, also use confirmation timer (same detection logic)
|
||||
// This ensures we wait for Claude to finish before proceeding
|
||||
// Note: 'watching' is already handled above and returns early
|
||||
switch (this._state) {
|
||||
case 'waiting_update':
|
||||
this.startStepConfirmTimer('update');
|
||||
break;
|
||||
case 'waiting_clear':
|
||||
this.checkClearComplete(); // /clear is quick, no need to wait
|
||||
break;
|
||||
case 'waiting_init':
|
||||
this.startStepConfirmTimer('init');
|
||||
break;
|
||||
case 'waiting_kickstart':
|
||||
this.startStepConfirmTimer('kickstart');
|
||||
break;
|
||||
// Non-waiting states: completion message is ignored
|
||||
case 'confirming_idle':
|
||||
case 'ai_checking':
|
||||
case 'sending_update':
|
||||
case 'sending_clear':
|
||||
case 'sending_init':
|
||||
case 'monitoring_init':
|
||||
case 'sending_kickstart':
|
||||
case 'stopped':
|
||||
// Completion message during these states is ignored
|
||||
break;
|
||||
default:
|
||||
assertNever(this._state, `Unhandled RespawnState in completion detection: ${this._state}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _detectWorkingPattern(data: string, now: number): boolean {
|
||||
const isWorking = this.checkWorkingPattern(data);
|
||||
if (!isWorking) return false;
|
||||
|
||||
this.workingDetected = true;
|
||||
this.promptDetected = false;
|
||||
this.elicitationDetected = false; // Clear on new work cycle
|
||||
this.resetHookState(); // Clear hook signals on new work
|
||||
this.lastWorkingPatternTime = now;
|
||||
|
||||
// Cancel hook confirmation timer if running
|
||||
this.cancelTrackedTimer('hook-confirm', 'working patterns detected');
|
||||
|
||||
// Cancel any pending completion confirmation
|
||||
this.cancelCompletionConfirm();
|
||||
|
||||
// Cancel any pending step confirmation (Claude is still working)
|
||||
this.cancelStepConfirm();
|
||||
|
||||
// If AI check is running, cancel it (Claude is working)
|
||||
if (this._state === 'ai_checking') {
|
||||
this.log('Working patterns detected during AI check, cancelling');
|
||||
this.aiChecker.cancel();
|
||||
this.setState('watching');
|
||||
}
|
||||
|
||||
// Cancel plan check if running (Claude started working)
|
||||
if (this.planChecker.status === 'checking') {
|
||||
this.log('Working patterns detected during plan check, cancelling');
|
||||
this.planChecker.cancel();
|
||||
}
|
||||
|
||||
// If we're monitoring init and work started, go to watching (no kickstart needed)
|
||||
if (this._state === 'monitoring_init') {
|
||||
this.log('/init triggered work, skipping kickstart');
|
||||
this.emit('stepCompleted', 'init');
|
||||
this.completeCycle();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _detectPrompt(data: string): void {
|
||||
const hasPrompt = PROMPT_PATTERNS.some((pattern) => data.includes(pattern));
|
||||
if (!hasPrompt) return;
|
||||
|
||||
this.promptDetected = true;
|
||||
this.workingDetected = false;
|
||||
|
||||
// Handle legacy detection in waiting states - also use confirmation timers
|
||||
switch (this._state) {
|
||||
case 'waiting_update':
|
||||
this.startStepConfirmTimer('update');
|
||||
break;
|
||||
case 'waiting_clear':
|
||||
this.checkClearComplete(); // /clear is quick, no need to wait
|
||||
break;
|
||||
case 'waiting_init':
|
||||
this.startStepConfirmTimer('init');
|
||||
break;
|
||||
case 'monitoring_init':
|
||||
this.checkMonitoringInitIdle();
|
||||
break;
|
||||
case 'waiting_kickstart':
|
||||
this.startStepConfirmTimer('kickstart');
|
||||
break;
|
||||
// Non-waiting states: prompt detection is informational only
|
||||
case 'watching':
|
||||
case 'confirming_idle':
|
||||
case 'ai_checking':
|
||||
case 'sending_update':
|
||||
case 'sending_clear':
|
||||
case 'sending_init':
|
||||
case 'sending_kickstart':
|
||||
case 'stopped':
|
||||
// Prompt detection during these states doesn't trigger action
|
||||
break;
|
||||
default:
|
||||
assertNever(this._state, `Unhandled RespawnState in prompt detection: ${this._state}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2066,6 +2078,18 @@ export class RespawnController extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for active child processes (bash tools, test suites, builds, etc.)
|
||||
// These may produce no terminal output, so restart timers to retry periodically.
|
||||
const activeProcesses = this.session.getActiveChildProcesses();
|
||||
if (activeProcesses.length > 0) {
|
||||
const names = activeProcesses.map((p) => p.command).join(', ');
|
||||
this.log(`Skipping AI check - ${activeProcesses.length} active child process(es): ${names}`);
|
||||
this.logAction('detection', `Skipped AI check: child processes running (${names})`);
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
// If AI check is disabled or errored out, fall back to direct idle confirmation
|
||||
if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') {
|
||||
this.log(`AI check unavailable (${this.aiChecker.status}), confirming idle directly via: ${reason}`);
|
||||
@@ -2161,7 +2185,7 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
if (this._state === 'stopped') return; // Guard against stopped state
|
||||
if (this._state === 'ai_checking') {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
const errorMsg = getErrorMessage(err);
|
||||
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
||||
this.emit('aiCheckFailed', errorMsg);
|
||||
this.setState('watching');
|
||||
@@ -2360,7 +2384,7 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
const errorMsg = getErrorMessage(err);
|
||||
this.emit('planCheckFailed', errorMsg);
|
||||
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
||||
});
|
||||
@@ -2670,6 +2694,18 @@ export class RespawnController extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Safety check: if child processes are running (bash tools, test suites, builds, etc.)
|
||||
const activeProcesses = this.session.getActiveChildProcesses();
|
||||
if (activeProcesses.length > 0) {
|
||||
const names = activeProcesses.map((p) => p.command).join(', ');
|
||||
this.log(`Idle confirmation rejected - ${activeProcesses.length} active child process(es): ${names}`);
|
||||
this.logAction('detection', `Rejected: child processes running (${names})`);
|
||||
this.setState('watching');
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(`Idle confirmed via: ${reason}`);
|
||||
const status = this.getDetectionStatus();
|
||||
this.log(
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as pty from 'node-pty';
|
||||
import {
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
ActiveBashTool,
|
||||
NiceConfig,
|
||||
DEFAULT_NICE_CONFIG,
|
||||
getErrorMessage,
|
||||
type ClaudeMode,
|
||||
type SessionMode,
|
||||
type OpenCodeConfig,
|
||||
@@ -64,6 +66,7 @@ import {
|
||||
MAX_MESSAGES,
|
||||
MAX_LINE_BUFFER_SIZE,
|
||||
} from './config/buffer-limits.js';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import {
|
||||
buildInteractiveArgs,
|
||||
buildPromptArgs,
|
||||
@@ -271,6 +274,7 @@ export class Session extends EventEmitter {
|
||||
private _lastPromptTime: number = 0;
|
||||
private activityTimeout: NodeJS.Timeout | null = null;
|
||||
private _awaitingIdleConfirmation: boolean = false; // Prevents timeout reset during idle detection
|
||||
private _trustDialogAccepted: boolean = false; // Prevents repeated trust dialog auto-accept
|
||||
private _taskTracker: TaskTracker;
|
||||
|
||||
// Token tracking for auto-clear
|
||||
@@ -526,6 +530,11 @@ export class Session extends EventEmitter {
|
||||
return this._claudeSessionId;
|
||||
}
|
||||
|
||||
/** The tmux session name, if the session is running inside a mux */
|
||||
get muxName(): string | null {
|
||||
return this._muxSession?.muxName ?? null;
|
||||
}
|
||||
|
||||
get totalCost(): number {
|
||||
return this._totalCost;
|
||||
}
|
||||
@@ -538,6 +547,49 @@ export class Session extends EventEmitter {
|
||||
return this._isWorking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the session's process tree has active child processes beyond Claude itself.
|
||||
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
|
||||
*
|
||||
* The tmux pane PID is typically "claude" directly (bash exec'd into it). When Claude
|
||||
* runs a bash tool, it spawns child processes: claude → bash → npm/node/python/etc.
|
||||
* We check direct children of the pane PID, filtering out "claude" itself (for the rare
|
||||
* case where bash wraps claude and didn't exec).
|
||||
*
|
||||
* Returns an array of {pid, command} for each child process, or empty array if none.
|
||||
* Returns empty array if no mux session or on error (fail-open to avoid blocking respawn).
|
||||
*/
|
||||
getActiveChildProcesses(): { pid: number; command: string }[] {
|
||||
if (!this._muxSession) return [];
|
||||
|
||||
try {
|
||||
const panePid = this._muxSession.pid;
|
||||
|
||||
// Single call: get direct children with their command names
|
||||
const output = execSync(`ps -o pid=,comm= --ppid ${panePid} 2>/dev/null`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (!output) return [];
|
||||
|
||||
const activeProcesses: { pid: number; command: string }[] = [];
|
||||
for (const line of output.split('\n')) {
|
||||
const match = line.trim().match(/^(\d+)\s+(.+)/);
|
||||
if (!match) continue;
|
||||
const pid = parseInt(match[1], 10);
|
||||
const command = match[2].trim();
|
||||
// Skip the claude process itself (pane_pid may be bash wrapping claude)
|
||||
if (command === 'claude') continue;
|
||||
activeProcesses.push({ pid, command });
|
||||
}
|
||||
|
||||
return activeProcesses;
|
||||
} catch {
|
||||
// ps returns exit code 1 when no matches — normal (no children)
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
get lastPromptTime(): number {
|
||||
return this._lastPromptTime;
|
||||
}
|
||||
@@ -871,6 +923,71 @@ export class Session extends EventEmitter {
|
||||
* session.write('help me with this code\r');
|
||||
* ```
|
||||
*/
|
||||
private async _setupOrAttachMuxSession(options: {
|
||||
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
|
||||
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
|
||||
spawnErrLabel: string;
|
||||
}): Promise<{ isRestored: boolean }> {
|
||||
const mux = this._mux!;
|
||||
|
||||
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
|
||||
if (this._muxSession && !mux.muxSessionExists(this._muxSession.muxName)) {
|
||||
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
|
||||
this._muxSession = null;
|
||||
}
|
||||
|
||||
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
|
||||
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
|
||||
let needsNewSession = false;
|
||||
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
|
||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||
const newPid = await mux.respawnPane(options.respawnPaneOptions);
|
||||
if (!newPid) {
|
||||
console.error('[Session] Failed to respawn pane, will create new session');
|
||||
needsNewSession = true;
|
||||
} else {
|
||||
// Wait a moment for the respawned process to fully start
|
||||
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we already have a mux session (restored session)
|
||||
const isRestored = this._muxSession !== null && !needsNewSession;
|
||||
if (isRestored) {
|
||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||
} else {
|
||||
// Create a new mux session
|
||||
this._muxSession = await mux.createSession(options.createSessionOptions);
|
||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||
// No extra sleep — createSession() already waits for tmux readiness
|
||||
}
|
||||
|
||||
// Attach to the mux session via PTY
|
||||
try {
|
||||
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: buildMuxAttachEnv(),
|
||||
});
|
||||
} catch (spawnErr) {
|
||||
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
|
||||
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
||||
throw spawnErr;
|
||||
}
|
||||
|
||||
return { isRestored };
|
||||
}
|
||||
|
||||
private _handleTerminalOutput(data: string): void {
|
||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||
this._terminalBuffer.append(data);
|
||||
this._lastActivityAt = Date.now();
|
||||
this.emit('terminal', data);
|
||||
this.emit('output', data);
|
||||
}
|
||||
|
||||
async startInteractive(): Promise<void> {
|
||||
if (this.ptyProcess) {
|
||||
throw new Error('Session already has a running process');
|
||||
@@ -886,18 +1003,8 @@ export class Session extends EventEmitter {
|
||||
// If mux wrapping is enabled, create or attach to a mux session
|
||||
if (this._useMux && this._mux) {
|
||||
try {
|
||||
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
|
||||
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) {
|
||||
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
|
||||
this._muxSession = null;
|
||||
}
|
||||
|
||||
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
|
||||
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
|
||||
let needsNewSession = false;
|
||||
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||
const newPid = await this._mux.respawnPane({
|
||||
const { isRestored } = await this._setupOrAttachMuxSession({
|
||||
respawnPaneOptions: {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
@@ -907,23 +1014,8 @@ export class Session extends EventEmitter {
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
});
|
||||
if (!newPid) {
|
||||
console.error('[Session] Failed to respawn pane, will create new session');
|
||||
needsNewSession = true;
|
||||
} else {
|
||||
// Wait a moment for the respawned process to fully start
|
||||
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we already have a mux session (restored session)
|
||||
const isRestoredSession = this._muxSession !== null && !needsNewSession;
|
||||
if (isRestoredSession) {
|
||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||
} else {
|
||||
// Create a new mux session
|
||||
this._muxSession = await this._mux.createSession({
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
@@ -934,36 +1026,16 @@ export class Session extends EventEmitter {
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
});
|
||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||
// No extra sleep — createSession() already waits for tmux readiness
|
||||
}
|
||||
},
|
||||
spawnErrLabel: 'mux attachment',
|
||||
});
|
||||
|
||||
// Attach to the mux session via PTY
|
||||
try {
|
||||
this.ptyProcess = pty.spawn(
|
||||
this._mux.getAttachCommand(),
|
||||
this._mux.getAttachArgs(this._muxSession!.muxName),
|
||||
{
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: buildMuxAttachEnv(),
|
||||
}
|
||||
);
|
||||
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
this._claudeSessionId = this._resumeSessionId || this.id;
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn PTY for mux attachment:', spawnErr);
|
||||
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
||||
throw spawnErr;
|
||||
}
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
this._claudeSessionId = this._resumeSessionId || this.id;
|
||||
|
||||
// For NEW mux sessions: wait for readiness then clean buffer
|
||||
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
|
||||
if (!isRestoredSession) {
|
||||
if (!isRestored) {
|
||||
if (this.mode === 'opencode') {
|
||||
// OpenCode uses Bubble Tea TUI — no ❯ prompt to detect.
|
||||
// Wait for TUI to stabilize (output stops changing), then mark ready.
|
||||
@@ -1050,12 +1122,17 @@ export class Session extends EventEmitter {
|
||||
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '').replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
|
||||
if (!data) return; // Skip if only filtered sequences
|
||||
|
||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||
this._terminalBuffer.append(data);
|
||||
this._lastActivityAt = Date.now();
|
||||
this._handleTerminalOutput(data);
|
||||
|
||||
this.emit('terminal', data);
|
||||
this.emit('output', data);
|
||||
// === Auto-accept workspace trust dialog ===
|
||||
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
|
||||
// Codeman sessions always use --dangerously-skip-permissions, so auto-accept.
|
||||
if (!this._trustDialogAccepted && data.includes('trust this folder')) {
|
||||
this._trustDialogAccepted = true;
|
||||
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
|
||||
// Send Enter to accept the default selection ("Yes, I trust this folder")
|
||||
this.writeViaMux('\r');
|
||||
}
|
||||
|
||||
// === Idle/working detection runs on every chunk (latency-sensitive) ===
|
||||
// Detect if Claude is working or at prompt
|
||||
@@ -1263,69 +1340,26 @@ export class Session extends EventEmitter {
|
||||
// If mux wrapping is enabled, create or attach to a mux session
|
||||
if (this._useMux && this._mux) {
|
||||
try {
|
||||
// Verify stale mux session — tmux may have been destroyed externally
|
||||
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) {
|
||||
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
|
||||
this._muxSession = null;
|
||||
}
|
||||
|
||||
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
|
||||
let needsNewSession = false;
|
||||
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||
const newPid = await this._mux.respawnPane({
|
||||
const { isRestored } = await this._setupOrAttachMuxSession({
|
||||
respawnPaneOptions: {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: 'shell',
|
||||
niceConfig: this._niceConfig,
|
||||
});
|
||||
if (!newPid) {
|
||||
console.error('[Session] Failed to respawn pane, will create new session');
|
||||
needsNewSession = true;
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we already have a mux session (restored session)
|
||||
const isRestoredSession = this._muxSession !== null && !needsNewSession;
|
||||
if (isRestoredSession) {
|
||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||
} else {
|
||||
// Create a new mux session
|
||||
this._muxSession = await this._mux.createSession({
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: 'shell',
|
||||
name: this._name,
|
||||
niceConfig: this._niceConfig,
|
||||
});
|
||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||
// No extra sleep — createSession() already waits for tmux readiness
|
||||
}
|
||||
|
||||
// Attach to the mux session via PTY
|
||||
try {
|
||||
this.ptyProcess = pty.spawn(
|
||||
this._mux.getAttachCommand(),
|
||||
this._mux.getAttachArgs(this._muxSession!.muxName),
|
||||
{
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: buildMuxAttachEnv(),
|
||||
}
|
||||
);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr);
|
||||
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
||||
throw spawnErr;
|
||||
}
|
||||
},
|
||||
spawnErrLabel: 'shell mux attachment',
|
||||
});
|
||||
|
||||
// For NEW sessions: clear by sending 'clear' command to the shell
|
||||
// For RESTORED sessions: don't clear - we want to see the existing output
|
||||
if (!isRestoredSession) {
|
||||
if (!isRestored) {
|
||||
setTimeout(() => {
|
||||
if (this.ptyProcess) {
|
||||
this._terminalBuffer.clear();
|
||||
@@ -1366,12 +1400,7 @@ export class Session extends EventEmitter {
|
||||
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '');
|
||||
if (!data) return; // Skip if only focus sequences
|
||||
|
||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||
this._terminalBuffer.append(data);
|
||||
this._lastActivityAt = Date.now();
|
||||
|
||||
this.emit('terminal', data);
|
||||
this.emit('output', data);
|
||||
this._handleTerminalOutput(data);
|
||||
});
|
||||
|
||||
this.ptyProcess.onExit(({ exitCode }) => {
|
||||
@@ -1479,12 +1508,7 @@ export class Session extends EventEmitter {
|
||||
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '');
|
||||
if (!data) return; // Skip if only focus sequences
|
||||
|
||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||
this._terminalBuffer.append(data);
|
||||
this._lastActivityAt = Date.now();
|
||||
|
||||
this.emit('terminal', data);
|
||||
this.emit('output', data);
|
||||
this._handleTerminalOutput(data);
|
||||
|
||||
// Also try to parse JSON lines for structured data
|
||||
this.processOutput(data);
|
||||
@@ -1516,9 +1540,11 @@ export class Session extends EventEmitter {
|
||||
this._status = 'idle';
|
||||
const cost = resultMsg.total_cost_usd || 0;
|
||||
this._totalCost += cost;
|
||||
this.emit('completion', resultMsg.result || '', cost);
|
||||
// Claude CLI stream-json may return empty result field — fall back to accumulated text output
|
||||
const result = resultMsg.result || this._textOutput.value || '';
|
||||
this.emit('completion', result, cost);
|
||||
if (resolve) {
|
||||
resolve({ result: resultMsg.result || '', cost });
|
||||
resolve({ result, cost });
|
||||
}
|
||||
} else if (exitCode !== 0 || (resultMsg && resultMsg.is_error)) {
|
||||
this._status = 'error';
|
||||
@@ -1991,7 +2017,7 @@ export class Session extends EventEmitter {
|
||||
this._status = 'busy';
|
||||
this._lastActivityAt = Date.now();
|
||||
this.runPrompt(input).catch((err) => {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
const errorMsg = getErrorMessage(err);
|
||||
// Clean up task state so the task queue doesn't get stuck
|
||||
if (this._currentTaskId) {
|
||||
const taskId = this._currentTaskId;
|
||||
|
||||
@@ -116,6 +116,26 @@ export class StateStore {
|
||||
this.loadRalphStates();
|
||||
}
|
||||
|
||||
private _mergeWithInitialState(parsed: Partial<AppState>): AppState {
|
||||
const initial = createInitialState();
|
||||
return {
|
||||
...initial,
|
||||
...parsed,
|
||||
sessions: { ...parsed.sessions },
|
||||
tasks: { ...parsed.tasks },
|
||||
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
|
||||
config: { ...initial.config, ...parsed.config },
|
||||
};
|
||||
}
|
||||
|
||||
private _resetCircuitBreaker(): void {
|
||||
this.consecutiveSaveFailures = 0;
|
||||
if (this.circuitBreakerOpen) {
|
||||
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
|
||||
this.circuitBreakerOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureDir(): void {
|
||||
const dir = dirname(this.filePath);
|
||||
if (!existsSync(dir)) {
|
||||
@@ -132,15 +152,7 @@ export class StateStore {
|
||||
if (existsSync(path)) {
|
||||
const data = readFileSync(path, 'utf-8');
|
||||
const parsed = JSON.parse(data) as Partial<AppState>;
|
||||
const initial = createInitialState();
|
||||
const result = {
|
||||
...initial,
|
||||
...parsed,
|
||||
sessions: { ...parsed.sessions },
|
||||
tasks: { ...parsed.tasks },
|
||||
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
|
||||
config: { ...initial.config, ...parsed.config },
|
||||
};
|
||||
const result = this._mergeWithInitialState(parsed);
|
||||
if (path !== this.filePath) {
|
||||
console.warn(`[StateStore] Recovered state from backup: ${path}`);
|
||||
}
|
||||
@@ -297,7 +309,7 @@ export class StateStore {
|
||||
|
||||
this.ensureDir();
|
||||
|
||||
const tempPath = this.filePath + '.tmp';
|
||||
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)
|
||||
@@ -321,11 +333,7 @@ export class StateStore {
|
||||
await writeFile(tempPath, json, 'utf-8');
|
||||
await rename(tempPath, this.filePath);
|
||||
|
||||
this.consecutiveSaveFailures = 0;
|
||||
if (this.circuitBreakerOpen) {
|
||||
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
|
||||
this.circuitBreakerOpen = false;
|
||||
}
|
||||
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
|
||||
@@ -365,7 +373,7 @@ export class StateStore {
|
||||
|
||||
this.ensureDir();
|
||||
|
||||
const tempPath = this.filePath + '.tmp';
|
||||
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
||||
const backupPath = this.filePath + '.bak';
|
||||
|
||||
const json = this.serializeState();
|
||||
@@ -385,11 +393,7 @@ export class StateStore {
|
||||
renameSync(tempPath, this.filePath);
|
||||
// Clear dirty flag only AFTER successful write
|
||||
this.dirty = false;
|
||||
this.consecutiveSaveFailures = 0;
|
||||
if (this.circuitBreakerOpen) {
|
||||
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
|
||||
this.circuitBreakerOpen = false;
|
||||
}
|
||||
this._resetCircuitBreaker();
|
||||
} catch (err) {
|
||||
console.error('[StateStore] Failed to write state file:', err);
|
||||
this.consecutiveSaveFailures++;
|
||||
@@ -415,15 +419,7 @@ export class StateStore {
|
||||
if (existsSync(backupPath)) {
|
||||
const backupContent = readFileSync(backupPath, 'utf-8');
|
||||
const parsed = JSON.parse(backupContent) as Partial<AppState>;
|
||||
const initial = createInitialState();
|
||||
this.state = {
|
||||
...initial,
|
||||
...parsed,
|
||||
sessions: { ...parsed.sessions },
|
||||
tasks: { ...parsed.tasks },
|
||||
ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop },
|
||||
config: { ...initial.config, ...parsed.config },
|
||||
};
|
||||
this.state = this._mergeWithInitialState(parsed);
|
||||
console.log('[StateStore] Successfully recovered state from backup');
|
||||
// Reset circuit breaker after successful recovery
|
||||
this.circuitBreakerOpen = false;
|
||||
|
||||
@@ -988,6 +988,24 @@ export class SubagentWatcher extends EventEmitter {
|
||||
return truncated.replace(/[.!?,:\s]+$/, '');
|
||||
}
|
||||
|
||||
private async _resolveDescription(
|
||||
projectHash: string,
|
||||
sessionId: string,
|
||||
agentId: string,
|
||||
filePath: string,
|
||||
fallbackText?: string
|
||||
): Promise<string | undefined> {
|
||||
// First try parent transcript (most reliable)
|
||||
const fromParent = await this.extractDescriptionFromParentTranscript(projectHash, sessionId, agentId);
|
||||
if (fromParent) return fromParent;
|
||||
|
||||
// Fallback: inline text (from processEntry) or file extraction
|
||||
if (fallbackText) {
|
||||
return this.extractSmartTitle(fallbackText);
|
||||
}
|
||||
return this.extractDescriptionFromFile(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the short description from the parent session's transcript.
|
||||
* This is the most reliable method because it reads the actual Task tool result
|
||||
@@ -1245,18 +1263,13 @@ export class SubagentWatcher extends EventEmitter {
|
||||
|
||||
// Retry description extraction if missing (race condition fix)
|
||||
if (!existingInfo.description) {
|
||||
// First try parent transcript (most reliable)
|
||||
let extractedDescription = await this.extractDescriptionFromParentTranscript(
|
||||
const extractedDescription = await this._resolveDescription(
|
||||
existingInfo.projectHash,
|
||||
existingInfo.sessionId,
|
||||
agentId
|
||||
agentId,
|
||||
filePath
|
||||
);
|
||||
// Fallback to subagent file
|
||||
if (!extractedDescription) {
|
||||
extractedDescription = await this.extractDescriptionFromFile(filePath);
|
||||
}
|
||||
if (extractedDescription) {
|
||||
// Check if this is an internal agent - if so, remove it
|
||||
if (this.isInternalAgent(extractedDescription)) {
|
||||
this.removeAgent(agentId);
|
||||
return;
|
||||
@@ -1307,13 +1320,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
}
|
||||
|
||||
// Extract description - prefer reading from parent transcript (most reliable)
|
||||
// The parent transcript has the exact Task tool call with description parameter
|
||||
let description = await this.extractDescriptionFromParentTranscript(projectHash, sessionId, agentId);
|
||||
|
||||
// Fallback: extract a smart title from the subagent's prompt if parent lookup failed
|
||||
if (!description) {
|
||||
description = await this.extractDescriptionFromFile(filePath);
|
||||
}
|
||||
const description = await this._resolveDescription(projectHash, sessionId, agentId, filePath);
|
||||
|
||||
// Skip internal Claude Code agents (e.g., suggestion mode) - not real subagents
|
||||
if (this.isInternalAgent(description)) {
|
||||
@@ -1406,43 +1413,11 @@ export class SubagentWatcher extends EventEmitter {
|
||||
private async processEntry(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): Promise<void> {
|
||||
const info = this.agentInfo.get(agentId);
|
||||
|
||||
// Extract model from assistant messages (first one sets the model)
|
||||
if (info && entry.type === 'assistant' && entry.message?.model && !info.model) {
|
||||
info.model = entry.message.model;
|
||||
info.modelShort = this.extractModelShort(entry.message.model);
|
||||
this.emit('subagent:updated', info);
|
||||
}
|
||||
if (info) {
|
||||
this._processModelInfo(entry, info);
|
||||
this._processTokenInfo(entry, info);
|
||||
|
||||
// Aggregate token usage from messages
|
||||
if (info && entry.message?.usage) {
|
||||
if (entry.message.usage.input_tokens) {
|
||||
info.totalInputTokens = (info.totalInputTokens || 0) + entry.message.usage.input_tokens;
|
||||
}
|
||||
if (entry.message.usage.output_tokens) {
|
||||
info.totalOutputTokens = (info.totalOutputTokens || 0) + entry.message.usage.output_tokens;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is first user message and description is missing
|
||||
if (info && !info.description && entry.type === 'user' && entry.message?.content) {
|
||||
// First try parent transcript (most reliable)
|
||||
let description = await this.extractDescriptionFromParentTranscript(info.projectHash, info.sessionId, agentId);
|
||||
// Fallback: extract smart title from the prompt content
|
||||
if (!description) {
|
||||
const text = this.extractFirstTextContent(entry.message.content);
|
||||
if (text) {
|
||||
description = this.extractSmartTitle(text);
|
||||
}
|
||||
}
|
||||
if (description) {
|
||||
// Check if this is an internal agent - if so, remove it
|
||||
if (this.isInternalAgent(description)) {
|
||||
this.removeAgent(agentId);
|
||||
return;
|
||||
}
|
||||
info.description = description;
|
||||
this.emit('subagent:updated', info);
|
||||
}
|
||||
if (await this._processDescription(entry, agentId, info)) return;
|
||||
}
|
||||
|
||||
if (entry.type === 'progress' && entry.data) {
|
||||
@@ -1453,7 +1428,6 @@ export class SubagentWatcher extends EventEmitter {
|
||||
progressType: entry.data.type,
|
||||
query: entry.data.query,
|
||||
resultCount: entry.data.resultCount,
|
||||
// Extract hook event info if present
|
||||
hookEvent: entry.data.hookEvent,
|
||||
hookName:
|
||||
entry.data.hookName ||
|
||||
@@ -1463,115 +1437,166 @@ export class SubagentWatcher extends EventEmitter {
|
||||
};
|
||||
this.emit('subagent:progress', progress);
|
||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||
// Handle both string and array content formats
|
||||
if (typeof entry.message.content === 'string') {
|
||||
const text = entry.message.content.trim();
|
||||
if (text.length > 0) {
|
||||
const message: SubagentMessage = {
|
||||
this._processAssistantContent(entry, agentId, sessionId);
|
||||
} else if (entry.type === 'user' && entry.message?.content) {
|
||||
this._processUserContent(entry, agentId, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private _processModelInfo(entry: SubagentTranscriptEntry, agent: SubagentInfo): void {
|
||||
if (entry.type === 'assistant' && entry.message?.model && !agent.model) {
|
||||
agent.model = entry.message.model;
|
||||
agent.modelShort = this.extractModelShort(entry.message.model);
|
||||
this.emit('subagent:updated', agent);
|
||||
}
|
||||
}
|
||||
|
||||
private _processTokenInfo(entry: SubagentTranscriptEntry, agent: SubagentInfo): void {
|
||||
if (!entry.message?.usage) return;
|
||||
if (entry.message.usage.input_tokens) {
|
||||
agent.totalInputTokens = (agent.totalInputTokens || 0) + entry.message.usage.input_tokens;
|
||||
}
|
||||
if (entry.message.usage.output_tokens) {
|
||||
agent.totalOutputTokens = (agent.totalOutputTokens || 0) + entry.message.usage.output_tokens;
|
||||
}
|
||||
}
|
||||
|
||||
private async _processDescription(
|
||||
entry: SubagentTranscriptEntry,
|
||||
agentId: string,
|
||||
agent: SubagentInfo
|
||||
): Promise<boolean> {
|
||||
if (agent.description || entry.type !== 'user' || !entry.message?.content) return false;
|
||||
|
||||
const fallbackText = this.extractFirstTextContent(entry.message.content);
|
||||
const description = await this._resolveDescription(
|
||||
agent.projectHash,
|
||||
agent.sessionId,
|
||||
agentId,
|
||||
agent.filePath,
|
||||
fallbackText
|
||||
);
|
||||
if (description) {
|
||||
if (this.isInternalAgent(description)) {
|
||||
this.removeAgent(agentId);
|
||||
return true;
|
||||
}
|
||||
agent.description = description;
|
||||
this.emit('subagent:updated', agent);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private _processAssistantContent(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): void {
|
||||
const messageContent = entry.message!.content;
|
||||
if (typeof messageContent === 'string') {
|
||||
const text = messageContent.trim();
|
||||
if (text.length > 0) {
|
||||
const message: SubagentMessage = {
|
||||
agentId,
|
||||
sessionId,
|
||||
timestamp: entry.timestamp,
|
||||
role: 'assistant',
|
||||
text: text.substring(0, MESSAGE_TEXT_LIMIT),
|
||||
};
|
||||
this.emit('subagent:message', message);
|
||||
}
|
||||
} else {
|
||||
for (const content of messageContent) {
|
||||
if (content.type === 'tool_use' && content.name) {
|
||||
// Store toolUseId for linking to results, with timestamp for TTL cleanup
|
||||
if (content.id) {
|
||||
if (!this.pendingToolCalls.has(agentId)) {
|
||||
this.pendingToolCalls.set(agentId, new Map());
|
||||
}
|
||||
const agentCalls = this.pendingToolCalls.get(agentId)!;
|
||||
// Enforce size limit to prevent memory leak from rapid tool calls
|
||||
if (agentCalls.size >= MAX_PENDING_TOOL_CALLS) {
|
||||
// FIFO eviction: delete first (oldest) entry using Map insertion order
|
||||
const firstKey = agentCalls.keys().next().value;
|
||||
if (firstKey !== undefined) agentCalls.delete(firstKey);
|
||||
}
|
||||
agentCalls.set(content.id, {
|
||||
toolName: content.name,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
const toolCall: SubagentToolCall = {
|
||||
agentId,
|
||||
sessionId,
|
||||
timestamp: entry.timestamp,
|
||||
role: 'assistant',
|
||||
text: text.substring(0, MESSAGE_TEXT_LIMIT),
|
||||
tool: content.name,
|
||||
input: this.getTruncatedInput(content.name, content.input || {}),
|
||||
toolUseId: content.id,
|
||||
fullInput: content.input || {},
|
||||
};
|
||||
this.emit('subagent:message', message);
|
||||
}
|
||||
} else {
|
||||
for (const content of entry.message.content) {
|
||||
if (content.type === 'tool_use' && content.name) {
|
||||
// Store toolUseId for linking to results, with timestamp for TTL cleanup
|
||||
if (content.id) {
|
||||
if (!this.pendingToolCalls.has(agentId)) {
|
||||
this.pendingToolCalls.set(agentId, new Map());
|
||||
}
|
||||
const agentCalls = this.pendingToolCalls.get(agentId)!;
|
||||
// Enforce size limit to prevent memory leak from rapid tool calls
|
||||
if (agentCalls.size >= MAX_PENDING_TOOL_CALLS) {
|
||||
// FIFO eviction: delete first (oldest) entry using Map insertion order
|
||||
const firstKey = agentCalls.keys().next().value;
|
||||
if (firstKey !== undefined) agentCalls.delete(firstKey);
|
||||
}
|
||||
agentCalls.set(content.id, {
|
||||
toolName: content.name,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
this.emit('subagent:tool_call', toolCall);
|
||||
|
||||
const toolCall: SubagentToolCall = {
|
||||
// Update tool call count
|
||||
const agentInfo = this.agentInfo.get(agentId);
|
||||
if (agentInfo) {
|
||||
agentInfo.toolCallCount++;
|
||||
}
|
||||
} else if (content.type === 'tool_result' && content.tool_use_id) {
|
||||
this.emitToolResult(
|
||||
{ tool_use_id: content.tool_use_id, content: content.content, is_error: content.is_error },
|
||||
agentId,
|
||||
sessionId,
|
||||
entry.timestamp
|
||||
);
|
||||
} else if (content.type === 'text' && content.text) {
|
||||
const text = content.text.trim();
|
||||
if (text.length > 0) {
|
||||
const message: SubagentMessage = {
|
||||
agentId,
|
||||
sessionId,
|
||||
timestamp: entry.timestamp,
|
||||
tool: content.name,
|
||||
input: this.getTruncatedInput(content.name, content.input || {}),
|
||||
toolUseId: content.id,
|
||||
fullInput: content.input || {},
|
||||
role: 'assistant',
|
||||
text: text.substring(0, MESSAGE_TEXT_LIMIT),
|
||||
};
|
||||
this.emit('subagent:tool_call', toolCall);
|
||||
|
||||
// Update tool call count
|
||||
const agentInfo = this.agentInfo.get(agentId);
|
||||
if (agentInfo) {
|
||||
agentInfo.toolCallCount++;
|
||||
}
|
||||
} else if (content.type === 'tool_result' && content.tool_use_id) {
|
||||
// Extract tool result
|
||||
this.emitToolResult(
|
||||
{ tool_use_id: content.tool_use_id, content: content.content, is_error: content.is_error },
|
||||
agentId,
|
||||
sessionId,
|
||||
entry.timestamp
|
||||
);
|
||||
} else if (content.type === 'text' && content.text) {
|
||||
const text = content.text.trim();
|
||||
if (text.length > 0) {
|
||||
const message: SubagentMessage = {
|
||||
agentId,
|
||||
sessionId,
|
||||
timestamp: entry.timestamp,
|
||||
role: 'assistant',
|
||||
text: text.substring(0, MESSAGE_TEXT_LIMIT), // Limit text length
|
||||
};
|
||||
this.emit('subagent:message', message);
|
||||
}
|
||||
this.emit('subagent:message', message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (entry.type === 'user' && entry.message?.content) {
|
||||
// Handle both string and array content formats - also check for tool_result in user messages
|
||||
if (typeof entry.message.content === 'string') {
|
||||
const userText = entry.message.content.trim();
|
||||
if (userText.length > 0 && userText.length < 500) {
|
||||
const message: SubagentMessage = {
|
||||
}
|
||||
}
|
||||
|
||||
private _processUserContent(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): void {
|
||||
const messageContent = entry.message!.content;
|
||||
if (typeof messageContent === 'string') {
|
||||
const userText = messageContent.trim();
|
||||
if (userText.length > 0 && userText.length < 500) {
|
||||
const message: SubagentMessage = {
|
||||
agentId,
|
||||
sessionId,
|
||||
timestamp: entry.timestamp,
|
||||
role: 'user',
|
||||
text: userText,
|
||||
};
|
||||
this.emit('subagent:message', message);
|
||||
}
|
||||
} else {
|
||||
// Check for tool_result blocks in user messages (common pattern)
|
||||
for (const content of messageContent) {
|
||||
if (content.type === 'tool_result' && content.tool_use_id) {
|
||||
this.emitToolResult(
|
||||
{ tool_use_id: content.tool_use_id, content: content.content, is_error: content.is_error },
|
||||
agentId,
|
||||
sessionId,
|
||||
timestamp: entry.timestamp,
|
||||
role: 'user',
|
||||
text: userText,
|
||||
};
|
||||
this.emit('subagent:message', message);
|
||||
}
|
||||
} else {
|
||||
// Check for tool_result blocks in user messages (common pattern)
|
||||
for (const content of entry.message.content) {
|
||||
if (content.type === 'tool_result' && content.tool_use_id) {
|
||||
this.emitToolResult(
|
||||
{ tool_use_id: content.tool_use_id, content: content.content, is_error: content.is_error },
|
||||
entry.timestamp
|
||||
);
|
||||
} else if (content.type === 'text' && content.text) {
|
||||
const userText = content.text.trim();
|
||||
if (userText.length > 0 && userText.length < 500) {
|
||||
const message: SubagentMessage = {
|
||||
agentId,
|
||||
sessionId,
|
||||
entry.timestamp
|
||||
);
|
||||
} else if (content.type === 'text' && content.text) {
|
||||
const userText = content.text.trim();
|
||||
if (userText.length > 0 && userText.length < 500) {
|
||||
const message: SubagentMessage = {
|
||||
agentId,
|
||||
sessionId,
|
||||
timestamp: entry.timestamp,
|
||||
role: 'user',
|
||||
text: userText,
|
||||
};
|
||||
this.emit('subagent:message', message);
|
||||
}
|
||||
timestamp: entry.timestamp,
|
||||
role: 'user',
|
||||
text: userText,
|
||||
};
|
||||
this.emit('subagent:message', message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,8 +185,8 @@ function buildSpawnCommand(options: {
|
||||
}): string {
|
||||
if (options.mode === 'claude') {
|
||||
// Validate model to prevent command injection
|
||||
const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined;
|
||||
const modelFlag = safeModel ? ` --model ${safeModel}` : '';
|
||||
const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined;
|
||||
const modelFlag = safeModel ? ` --model "${safeModel}"` : '';
|
||||
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
|
||||
// Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
|
||||
// fall back to a new session with --session-id so the pane doesn't die.
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
RESTART_DELAY_MS,
|
||||
FORCE_KILL_MS,
|
||||
} from './config/tunnel-config.js';
|
||||
import { getErrorMessage } from './types.js';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -164,7 +165,7 @@ export class TunnelManager extends EventEmitter {
|
||||
detached: false,
|
||||
});
|
||||
} catch (err) {
|
||||
this.emit('error', `Failed to spawn cloudflared: ${err instanceof Error ? err.message : String(err)}`);
|
||||
this.emit('error', `Failed to spawn cloudflared: ${getErrorMessage(err)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -251,6 +251,23 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.ORCHESTRATOR_ERROR, '_onOrchestratorError'],
|
||||
];
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Name Prefix Parser
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Parses w<N>-<caseName> or s<N>-<caseName> prefix from session names.
|
||||
// Returns { prefix, suffix } or null if name does not match the pattern.
|
||||
function parseSessionPrefix(name) {
|
||||
if (!name) return null;
|
||||
const m = name.match(/^(w\d+-[a-zA-Z0-9_-]+|s\d+-[a-zA-Z0-9_-]+)/);
|
||||
if (!m) return null;
|
||||
const prefix = m[1];
|
||||
const rest = name.slice(prefix.length);
|
||||
if (rest === "") return { prefix, suffix: "" };
|
||||
if (rest.startsWith(": ")) return { prefix, suffix: rest.slice(2) };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CodemanApp Class — constructor and global state
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -523,6 +540,9 @@ class CodemanApp {
|
||||
SwipeHandler.init();
|
||||
VoiceInput.init();
|
||||
KeyboardAccessoryBar.init();
|
||||
// Apply keyboard bar mode from settings
|
||||
const _kbSettings = this.loadAppSettingsFromStorage();
|
||||
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
@@ -612,10 +632,8 @@ class CodemanApp {
|
||||
// shift? (require Shift), action }.
|
||||
const SHORTCUTS = [
|
||||
{ key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() },
|
||||
{ key: 'Enter', ctrl: true, action: () => this.quickStart() },
|
||||
{ key: 'w', ctrl: true, action: () => this.killActiveSession() },
|
||||
{ key: 'Tab', ctrl: true, action: () => this.nextSession() },
|
||||
{ key: 'k', ctrl: true, action: () => this.killAllSessions() },
|
||||
{ key: 'l', ctrl: true, action: () => this.clearTerminal() },
|
||||
{ key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() },
|
||||
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
|
||||
@@ -634,6 +652,16 @@ class CodemanApp {
|
||||
this.closeHelp();
|
||||
}
|
||||
|
||||
// Alt+1-9: switch to Codeman session by index
|
||||
if (e.altKey && !e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '9') {
|
||||
const idx = parseInt(e.key) - 1;
|
||||
if (idx < this.sessionOrder.length) {
|
||||
e.preventDefault();
|
||||
this.selectSession(this.sessionOrder[idx]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Match against shortcut table
|
||||
for (const s of SHORTCUTS) {
|
||||
const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
|
||||
@@ -866,6 +894,153 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Response Viewer — native-scroll panel for reading full Claude responses
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Strip dangerous elements and attributes from HTML (XSS prevention) */
|
||||
_sanitizeHtml(html) {
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = html;
|
||||
const frag = tpl.content;
|
||||
// Remove dangerous elements
|
||||
for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
|
||||
el.remove();
|
||||
}
|
||||
// Strip dangerous attributes from all elements
|
||||
for (const el of frag.querySelectorAll('*')) {
|
||||
for (const attr of [...el.attributes]) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (name.startsWith('on')) {
|
||||
el.removeAttribute(attr.name);
|
||||
} else if (['href', 'src', 'action', 'xlink:href', 'formaction'].includes(name)) {
|
||||
const val = attr.value.replace(/\s/g, '').toLowerCase();
|
||||
if (val.startsWith('javascript:') || val.startsWith('vbscript:') || val.startsWith('data:text/html')) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Serialize back via a container
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(frag);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
|
||||
_renderMarkdown(text) {
|
||||
if (typeof marked !== 'undefined' && marked.parse) {
|
||||
try {
|
||||
return this._sanitizeHtml(marked.parse(text, { breaks: true, gfm: true }));
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
// Fallback: escape HTML and preserve whitespace
|
||||
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
|
||||
}
|
||||
|
||||
async toggleResponseViewer() {
|
||||
const viewer = document.getElementById('responseViewer');
|
||||
const backdrop = document.getElementById('responseViewerBackdrop');
|
||||
if (!viewer) return;
|
||||
|
||||
const isOpen = viewer.classList.contains('visible');
|
||||
if (isOpen) {
|
||||
viewer.classList.remove('visible');
|
||||
backdrop.classList.remove('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.activeSessionId) return;
|
||||
try {
|
||||
// Source 1: Transcript JSONL (best quality — clean structured text from Claude)
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response`);
|
||||
const data = await res.json();
|
||||
let lastResponse = data.text || '';
|
||||
|
||||
// Source 2: Terminal buffer fallback (strip ANSI codes)
|
||||
if (!lastResponse) {
|
||||
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
|
||||
const termData = await termRes.json();
|
||||
if (termData.terminalBuffer) {
|
||||
lastResponse = termData.terminalBuffer
|
||||
.replace(/\x1b\[\?[0-9;]*[a-zA-Z]/g, '')
|
||||
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
||||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
||||
.replace(/\x1b[()][A-Z0-9]/g, '')
|
||||
.replace(/\x1b[>=<]/g, '')
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||
.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
.replace(/[ \t]+$/gm, '')
|
||||
.replace(/\n{4,}/g, '\n\n\n')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const body = document.getElementById('responseViewerBody');
|
||||
body.innerHTML = this._renderMarkdown(lastResponse);
|
||||
|
||||
// Reset state for fresh open
|
||||
const title = document.getElementById('responseViewerTitle');
|
||||
const moreBtn = document.getElementById('responseViewerMore');
|
||||
if (title) title.textContent = 'Last Response';
|
||||
if (moreBtn) { moreBtn.style.display = ''; moreBtn.textContent = 'More'; }
|
||||
|
||||
viewer.classList.add('visible');
|
||||
backdrop.classList.add('visible');
|
||||
body.scrollTop = 0;
|
||||
} catch (err) {
|
||||
console.error('Failed to load response:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async loadFullContext() {
|
||||
if (!this.activeSessionId) return;
|
||||
const moreBtn = document.getElementById('responseViewerMore');
|
||||
if (moreBtn) moreBtn.textContent = '...';
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response?context=full`);
|
||||
const data = await res.json();
|
||||
const messages = data.messages || [];
|
||||
const body = document.getElementById('responseViewerBody');
|
||||
const title = document.getElementById('responseViewerTitle');
|
||||
if (!body) return;
|
||||
|
||||
if (messages.length === 0) {
|
||||
body.textContent = 'No conversation history available';
|
||||
return;
|
||||
}
|
||||
|
||||
// Render conversation thread
|
||||
body.innerHTML = '';
|
||||
for (const msg of messages) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'rv-message';
|
||||
|
||||
const role = document.createElement('div');
|
||||
role.className = 'rv-role ' + (msg.role === 'user' ? 'rv-role-user' : 'rv-role-assistant');
|
||||
role.textContent = msg.role === 'user' ? 'You' : 'Claude';
|
||||
div.appendChild(role);
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'rv-text';
|
||||
text.innerHTML = this._renderMarkdown(msg.text);
|
||||
div.appendChild(text);
|
||||
|
||||
body.appendChild(div);
|
||||
}
|
||||
|
||||
if (title) title.textContent = `Conversation (${messages.length} messages)`;
|
||||
if (moreBtn) moreBtn.style.display = 'none';
|
||||
// Scroll to bottom (latest message)
|
||||
body.scrollTop = body.scrollHeight;
|
||||
} catch (err) {
|
||||
console.error('Failed to load context:', err);
|
||||
} finally {
|
||||
if (moreBtn) moreBtn.textContent = 'More';
|
||||
}
|
||||
}
|
||||
|
||||
async _onSessionNeedsRefresh() {
|
||||
// Server sends this after SSE backpressure clears — terminal data was dropped,
|
||||
// so reload the buffer to recover from any display corruption.
|
||||
@@ -939,15 +1114,7 @@ class CodemanApp {
|
||||
if (data.id === this.activeSessionId) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${data.error}\x1b[0m`);
|
||||
}
|
||||
const session = this.sessions.get(data.id);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'critical',
|
||||
category: 'session-error',
|
||||
sessionId: data.id,
|
||||
sessionName: session?.name || this.getShortId(data.id),
|
||||
title: 'Session Error',
|
||||
message: data.error || 'Unknown error',
|
||||
});
|
||||
this._notifySession(data.id, 'critical', 'session-error', 'Session Error', data.error || 'Unknown error');
|
||||
}
|
||||
|
||||
_onSessionExit(data) {
|
||||
@@ -960,14 +1127,7 @@ class CodemanApp {
|
||||
}
|
||||
// Notify on unexpected exit (non-zero code)
|
||||
if (data.code && data.code !== 0) {
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'critical',
|
||||
category: 'session-crash',
|
||||
sessionId: data.id,
|
||||
sessionName: session?.name || this.getShortId(data.id),
|
||||
title: 'Session Crashed',
|
||||
message: `Exited with code ${data.code}`,
|
||||
});
|
||||
this._notifySession(data.id, 'critical', 'session-crash', 'Session Crashed', `Exited with code ${data.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -984,15 +1144,7 @@ class CodemanApp {
|
||||
const threshold = this.notificationManager?.preferences?.stuckThresholdMs || 600000;
|
||||
clearTimeout(this.idleTimers.get(data.id));
|
||||
this.idleTimers.set(data.id, setTimeout(() => {
|
||||
const s = this.sessions.get(data.id);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'warning',
|
||||
category: 'session-stuck',
|
||||
sessionId: data.id,
|
||||
sessionName: s?.name || this.getShortId(data.id),
|
||||
title: 'Session Idle',
|
||||
message: `Idle for ${Math.round(threshold / 60000)}+ minutes`,
|
||||
});
|
||||
this._notifySession(data.id, 'warning', 'session-stuck', 'Session Idle', `Idle for ${Math.round(threshold / 60000)}+ minutes`);
|
||||
this.idleTimers.delete(data.id);
|
||||
}, threshold));
|
||||
}
|
||||
@@ -1023,15 +1175,7 @@ class CodemanApp {
|
||||
this.showToast(`Auto-cleared at ${data.tokens.toLocaleString()} tokens`, 'info');
|
||||
this.updateRespawnTokens(0);
|
||||
}
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'info',
|
||||
category: 'auto-clear',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || this.getShortId(data.sessionId),
|
||||
title: 'Auto-Cleared',
|
||||
message: `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`,
|
||||
});
|
||||
this._notifySession(data.sessionId, 'info', 'auto-clear', 'Auto-Cleared', `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`);
|
||||
}
|
||||
|
||||
_onSessionCliInfo(data) {
|
||||
@@ -1622,7 +1766,12 @@ class CodemanApp {
|
||||
// Update name if changed
|
||||
const nameEl = tab.querySelector('.tab-name');
|
||||
if (nameEl && nameEl.textContent !== name) {
|
||||
nameEl.textContent = name;
|
||||
const _p = parseSessionPrefix(name);
|
||||
if (_p && _p.suffix) {
|
||||
nameEl.innerHTML = '<span class="tab-prefix">' + escapeHtml(_p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(_p.suffix) + '</span>';
|
||||
} else {
|
||||
nameEl.textContent = name;
|
||||
}
|
||||
}
|
||||
|
||||
// Update task badge
|
||||
@@ -1730,7 +1879,7 @@ class CodemanApp {
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}">${escapeHtml(name)}</span>
|
||||
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
|
||||
</span>
|
||||
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
|
||||
</span>
|
||||
@@ -1983,6 +2132,18 @@ class CodemanApp {
|
||||
return this.getShortId(session.id);
|
||||
}
|
||||
|
||||
_notifySession(sessionId, urgency, category, title, message) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency,
|
||||
category,
|
||||
sessionId,
|
||||
sessionName: session?.name || this.getShortId(sessionId),
|
||||
title,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up state from the previous session before switching tabs.
|
||||
* Handles: WebSocket teardown, CJK clear, flicker filter, tab completion,
|
||||
@@ -2124,8 +2285,9 @@ class CodemanApp {
|
||||
|
||||
// Track working directory for path normalization in Project Insights
|
||||
this.currentSessionWorkingDir = session?.workingDir || null;
|
||||
if (session && session.pid === null && session.status === 'idle') {
|
||||
// This is a restored session - attach to the existing screen/shell
|
||||
if (session && session.pid === null && !session._ended) {
|
||||
// Session has no PTY attached — either restored after server restart
|
||||
// or detached for some other reason. Re-attach regardless of status.
|
||||
try {
|
||||
const endpoint = session.mode === 'shell'
|
||||
? `/api/sessions/${sessionId}/shell`
|
||||
|
||||
@@ -291,6 +291,12 @@ const SSE_EVENTS = {
|
||||
ORCHESTRATOR_TASK_FAILED: 'orchestrator:taskFailed',
|
||||
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
|
||||
ORCHESTRATOR_ERROR: 'orchestrator:error',
|
||||
|
||||
// Cases
|
||||
CASE_CREATED: 'case:created',
|
||||
CASE_LINKED: 'case:linked',
|
||||
CASE_DELETED: 'case:deleted',
|
||||
CASE_ORDER_CHANGED: 'case:order-changed',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
@@ -6,6 +6,9 @@
|
||||
<meta name="description" content="Claude Code session manager with web interface">
|
||||
<meta name="theme-color" content="#0a0a0a">
|
||||
<meta name="google" content="notranslate">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="apple-touch-icon" href="icon-192.png">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<title>Codeman</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%2360a5fa'/%3E%3Cstop offset='100%25' stop-color='%233b82f6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='6' fill='%230a0a0a'/%3E%3Cpath d='M18 4L8 18h6l-2 10 10-14h-6z' fill='url(%23g)'/%3E%3C/svg%3E">
|
||||
@@ -26,6 +29,7 @@
|
||||
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
|
||||
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
||||
<script defer src="vendor/xterm-zerolag-input.js"></script>
|
||||
<script defer src="vendor/marked.min.js"></script>
|
||||
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
|
||||
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
@@ -92,6 +96,7 @@
|
||||
<span class="stat-value" id="statMem">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-header btn-response-viewer-header" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||
@@ -331,6 +336,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Viewer (mobile: native-scroll overlay for reading full Claude responses) -->
|
||||
<div class="response-viewer" id="responseViewer">
|
||||
<div class="response-viewer-header">
|
||||
<span id="responseViewerTitle">Last Response</span>
|
||||
<div class="response-viewer-actions">
|
||||
<button class="response-viewer-more" id="responseViewerMore" onclick="app.loadFullContext()">More</button>
|
||||
<button class="response-viewer-close" onclick="app.toggleResponseViewer()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="response-viewer-body" id="responseViewerBody"></div>
|
||||
</div>
|
||||
<div class="response-viewer-backdrop" id="responseViewerBackdrop" onclick="app.toggleResponseViewer()"></div>
|
||||
|
||||
<!-- Bottom Toolbar -->
|
||||
<footer class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
@@ -384,6 +402,11 @@
|
||||
<span>Agent Teams</span>
|
||||
</label>
|
||||
<span class="form-hint">Enable experimental Agent Teams for new sessions in this case</span>
|
||||
<label class="checkbox-inline" style="margin-top: 6px;">
|
||||
<input type="checkbox" id="caseOpusContext1m" onchange="app.onCaseSettingChanged()">
|
||||
<span>1M Opus Context</span>
|
||||
</label>
|
||||
<span class="form-hint">Use 1M token context window for new sessions</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile-only case button + gear -->
|
||||
@@ -408,6 +431,11 @@
|
||||
<span>Agent Teams</span>
|
||||
</label>
|
||||
<span class="form-hint">Enable Agent Teams for new sessions</span>
|
||||
<label class="checkbox-inline" style="margin-top: 6px;">
|
||||
<input type="checkbox" id="caseOpusContext1mMobile" onchange="app.onCaseSettingChangedMobile()">
|
||||
<span>1M Opus Context</span>
|
||||
</label>
|
||||
<span class="form-hint">Use 1M token context window</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -449,10 +477,9 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Enter</kbd></div><div>Run Claude</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>K</kbd></div><div>Kill All Sessions + Tmux</div>
|
||||
<div><kbd>Alt</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
|
||||
@@ -649,7 +676,10 @@
|
||||
<div class="form-section-header">Appearance</div>
|
||||
<div class="form-row">
|
||||
<label>Session Name</label>
|
||||
<input type="text" id="modalSessionName" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()">
|
||||
<div style="display: flex; align-items: center; gap: 4px;">
|
||||
<span id="modalSessionPrefix" style="color: var(--text-muted); white-space: nowrap; display: none;"></span>
|
||||
<input type="text" id="modalSessionName" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()" style="flex: 1;">
|
||||
</div>
|
||||
<span class="form-hint">Custom name shown in the tab (right-click tab to rename inline)</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
@@ -882,6 +912,17 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-item settings-item-multiline" title="Show additional buttons (Tab, Shift+Tab, Ctrl+O, Esc, Alt+Enter, left/right arrows) in the mobile keyboard accessory bar.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">Extended Keyboard Bar</span>
|
||||
<span class="settings-item-desc">Extra keys: Tab, Esc, arrows, Ctrl+O</span>
|
||||
</div>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsExtendedKeyboardBar">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Header Displays Section -->
|
||||
<div class="settings-section-header">Header Displays</div>
|
||||
<div class="settings-item" title="Show A-/A+ font size buttons in header">
|
||||
@@ -1046,6 +1087,14 @@
|
||||
</label>
|
||||
<span class="form-hint">Enable experimental Agent Teams for all new Claude sessions (disabled by default)</span>
|
||||
</div>
|
||||
<div class="form-row form-row-switch">
|
||||
<label>1M Opus Context</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="appSettingsOpusContext1m">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="form-hint">Use 1M token context window (model: opus[1m]) for all new sessions</span>
|
||||
</div>
|
||||
<!-- Nice Priority Section -->
|
||||
<div class="form-section-header">Nice Priority</div>
|
||||
<div class="form-row form-row-switch">
|
||||
@@ -1067,6 +1116,7 @@
|
||||
<div class="form-row">
|
||||
<label>Default Model</label>
|
||||
<select id="appSettingsDefaultModel" class="form-select">
|
||||
<option value="">Default (CLI default)</option>
|
||||
<option value="opus">Opus (Most capable)</option>
|
||||
<option value="sonnet">Sonnet (Balanced)</option>
|
||||
<option value="haiku">Haiku (Fast & cheap)</option>
|
||||
@@ -1341,6 +1391,7 @@
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
|
||||
<button class="modal-tab-btn" data-tab="case-link">Link Existing</button>
|
||||
<button class="modal-tab-btn" data-tab="case-manage">Manage</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Create New Tab -->
|
||||
@@ -1368,6 +1419,13 @@
|
||||
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Manage Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-manage">
|
||||
<div class="case-manage-list" id="caseManageList">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<span class="form-hint" style="margin-top: 8px; display: block;">Use arrows to reorder. Changes are saved automatically.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn-toolbar" onclick="app.closeCreateCaseModal()">Cancel</button>
|
||||
|
||||
@@ -33,16 +33,10 @@
|
||||
*/
|
||||
const KeyboardAccessoryBar = {
|
||||
element: null,
|
||||
_mode: 'simple', // 'simple' or 'extended'
|
||||
|
||||
/** 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 = `
|
||||
/** HTML for simple mode: arrows, commands, paste, dismiss */
|
||||
_simpleButtons: `
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M5 15l7-7 7 7"/>
|
||||
@@ -66,8 +60,59 @@ const KeyboardAccessoryBar = {
|
||||
<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>`,
|
||||
|
||||
/** HTML for extended mode: all keys including arrows, Tab, Esc, etc. */
|
||||
_extendedButtons: `
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M5 15l7-7 7 7"/>
|
||||
</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 accessory-btn-arrow" data-action="arrow-left" title="Arrow left">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-right" title="Arrow right">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</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" data-action="tab" title="Tab">Tab</button>
|
||||
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
|
||||
<button class="accessory-btn" data-action="ctrl-o" title="Ctrl+O">⌃O</button>
|
||||
<button class="accessory-btn" data-action="opt-enter" title="Option+Enter (newline)">⌥Enter</button>
|
||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</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 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 and inject the accessory bar */
|
||||
init() {
|
||||
// Only on mobile
|
||||
if (!MobileDetection.isTouchDevice()) return;
|
||||
|
||||
// Create accessory bar element
|
||||
this.element = document.createElement('div');
|
||||
this.element.className = 'keyboard-accessory-bar';
|
||||
this.element.innerHTML = this._simpleButtons;
|
||||
|
||||
// Add click handlers — preventDefault stops event from reaching terminal
|
||||
this.element.addEventListener('click', (e) => {
|
||||
@@ -80,7 +125,8 @@ const KeyboardAccessoryBar = {
|
||||
this.handleAction(action, btn);
|
||||
|
||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||
if ((action === 'scroll-up' || action === 'scroll-down') ||
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc']);
|
||||
if (refocusActions.has(action) ||
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
app.terminal.focus();
|
||||
@@ -95,6 +141,14 @@ const KeyboardAccessoryBar = {
|
||||
}
|
||||
},
|
||||
|
||||
/** Switch between 'simple' and 'extended' button layouts */
|
||||
setMode(mode) {
|
||||
if (mode === this._mode || !this.element) return;
|
||||
this._mode = mode;
|
||||
this.clearConfirm();
|
||||
this.element.innerHTML = mode === 'extended' ? this._extendedButtons : this._simpleButtons;
|
||||
},
|
||||
|
||||
_confirmTimer: null,
|
||||
_confirmAction: null,
|
||||
|
||||
@@ -109,6 +163,27 @@ const KeyboardAccessoryBar = {
|
||||
case 'scroll-down':
|
||||
this.sendKey('\x1b[B');
|
||||
break;
|
||||
case 'arrow-left':
|
||||
this.sendKey('\x1b[D');
|
||||
break;
|
||||
case 'arrow-right':
|
||||
this.sendKey('\x1b[C');
|
||||
break;
|
||||
case 'esc':
|
||||
this.sendKey('\x1b');
|
||||
break;
|
||||
case 'opt-enter':
|
||||
this.sendKey('\x1b\r');
|
||||
break;
|
||||
case 'tab':
|
||||
this.sendKey('\t');
|
||||
break;
|
||||
case 'shift-tab':
|
||||
this.sendKey('\x1b[Z');
|
||||
break;
|
||||
case 'ctrl-o':
|
||||
this.sendKey('\x0f');
|
||||
break;
|
||||
case 'init':
|
||||
this.sendCommand('/init');
|
||||
break;
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
{
|
||||
"name": "Codeman",
|
||||
"short_name": "Codeman",
|
||||
"description": "Claude Code session manager",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#0a0a0a"
|
||||
"theme_color": "#0a0a0a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -65,13 +65,14 @@ html.mobile-init .file-browser-panel {
|
||||
max-height: calc(48px + var(--safe-area-top));
|
||||
}
|
||||
|
||||
/* Add top margin to main content to account for fixed header */
|
||||
.main {
|
||||
margin-top: 54px;
|
||||
/* Push ALL content below fixed header (not just .main) so banners
|
||||
(respawn, timer, orchestrator) between header and main are visible */
|
||||
.app {
|
||||
padding-top: 54px;
|
||||
}
|
||||
|
||||
.ios-device .main {
|
||||
margin-top: calc(54px + var(--safe-area-top));
|
||||
.ios-device .app {
|
||||
padding-top: calc(54px + var(--safe-area-top));
|
||||
}
|
||||
|
||||
/* Font controls - smaller on tablet, visibility controlled by JS */
|
||||
@@ -235,6 +236,37 @@ html.mobile-init .file-browser-panel {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* ---- Respawn Banner: Tablet Optimizations ---- */
|
||||
|
||||
.respawn-banner {
|
||||
padding: 0.25rem 0.5rem;
|
||||
padding-left: calc(0.5rem + var(--safe-area-left));
|
||||
padding-right: calc(0.5rem + var(--safe-area-right));
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.respawn-compact-layout {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.respawn-status-row1 {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.respawn-action-log {
|
||||
max-height: 2.6em;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.respawn-countdown-timer {
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
/* Respawn timer bar — slimmer on tablet */
|
||||
.respawn-timer-bar {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
/* Show desktop voice button on tablet (hidden by max-width:1023px in styles.css,
|
||||
mobile .btn-voice-mobile only shows at <430px) */
|
||||
.toolbar-center .btn-toolbar.btn-voice {
|
||||
@@ -269,9 +301,15 @@ html.mobile-init .file-browser-panel {
|
||||
Phone Breakpoint (<430px)
|
||||
============================================================================ */
|
||||
@media (max-width: 430px) {
|
||||
/* Hide header brand on phones */
|
||||
/* Compact header brand on phones — acts as home button */
|
||||
.header-brand {
|
||||
display: none;
|
||||
padding-right: 0.25rem;
|
||||
margin-right: 0.2rem;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.header-brand .logo {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Font controls - compact on phones, visibility controlled by JS */
|
||||
@@ -327,18 +365,22 @@ html.mobile-init .file-browser-panel {
|
||||
max-height: calc(36px + var(--safe-area-top));
|
||||
}
|
||||
|
||||
/* Add top margin to main content to account for fixed header,
|
||||
and bottom padding for the fixed toolbar (40px) so terminal
|
||||
content doesn't extend behind it. JS overrides paddingBottom
|
||||
/* Push ALL content below fixed header (not just .main) so banners
|
||||
(respawn, timer, orchestrator) between header and main are visible.
|
||||
Bottom padding for the fixed toolbar (40px) so terminal content
|
||||
doesn't extend behind it. JS overrides paddingBottom on .main
|
||||
when keyboard is visible, and resetLayout() clears the inline
|
||||
style to re-expose this CSS value. */
|
||||
.main {
|
||||
margin-top: 42px;
|
||||
padding-bottom: calc(40px + var(--safe-area-bottom));
|
||||
.app {
|
||||
padding-top: 42px;
|
||||
}
|
||||
|
||||
.ios-device .main {
|
||||
margin-top: calc(42px + var(--safe-area-top));
|
||||
.ios-device .app {
|
||||
padding-top: calc(42px + var(--safe-area-top));
|
||||
}
|
||||
|
||||
.main {
|
||||
padding-bottom: calc(40px + var(--safe-area-bottom));
|
||||
}
|
||||
|
||||
/* iOS Safari: toolbar is pushed up by (100vh - --app-height) to clear the
|
||||
@@ -850,7 +892,8 @@ html.mobile-init .file-browser-panel {
|
||||
padding-right: calc(8px + var(--safe-area-right));
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
z-index: 51;
|
||||
transition: transform 0.15s ease-out;
|
||||
will-change: transform;
|
||||
@@ -860,10 +903,16 @@ html.mobile-init .file-browser-panel {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但保留滑动能力 */
|
||||
.keyboard-accessory-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accessory-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: #2a2a2a;
|
||||
@@ -894,31 +943,33 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
.accessory-btn-arrow {
|
||||
padding: 6px 10px;
|
||||
background: #1e3a5f;
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
color: #93c5fd;
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow:active {
|
||||
background: #2563eb;
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss {
|
||||
padding: 8px 14px;
|
||||
background: #2a2a2a;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 6px;
|
||||
color: #e5e5e5;
|
||||
margin-left: auto;
|
||||
flex: 1 1 0;
|
||||
max-width: 100px;
|
||||
padding: 10px 8px;
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss:active {
|
||||
background: #3a3a3a;
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
/* Voice preview — positioned above accessory bar on mobile */
|
||||
@@ -950,9 +1001,9 @@ html.mobile-init .file-browser-panel {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-bottom: env(safe-area-inset-bottom, 12px);
|
||||
padding-top: 15vh;
|
||||
}
|
||||
|
||||
.paste-dialog {
|
||||
@@ -962,13 +1013,12 @@ html.mobile-init .file-browser-panel {
|
||||
padding: 12px;
|
||||
width: calc(100% - 24px);
|
||||
max-width: 400px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.paste-textarea {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
max-height: 120px;
|
||||
min-height: 80px;
|
||||
max-height: 200px;
|
||||
background: var(--bg-primary, #0d0d14);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
@@ -1145,6 +1195,20 @@ html.mobile-init .file-browser-panel {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Response viewer — show eye icon in header on mobile */
|
||||
.btn-response-viewer-header {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
|
||||
.response-viewer {
|
||||
padding-bottom: var(--safe-area-bottom, 0px);
|
||||
}
|
||||
|
||||
.response-viewer-body {
|
||||
font-size: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Compact welcome overlay for mobile */
|
||||
.welcome-content {
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
@@ -1213,31 +1277,70 @@ html.mobile-init .file-browser-panel {
|
||||
/* ---- Respawn Banner: Mobile Optimizations ---- */
|
||||
|
||||
.respawn-banner {
|
||||
padding: 0.4rem 0.5rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
padding-left: calc(0.5rem + var(--safe-area-left));
|
||||
padding-right: calc(0.5rem + var(--safe-area-right));
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
/* Stack banner vertically on mobile — status on top, action log below */
|
||||
.respawn-compact-layout {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
/* Let status column shrink to fit */
|
||||
.respawn-status-col {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
/* Wrap status row items — tokens/timer may go to second line */
|
||||
/* Row1: wrap allowed, key info prioritized via order */
|
||||
.respawn-status-row1 {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
gap: 0.25rem;
|
||||
row-gap: 0.1rem;
|
||||
}
|
||||
|
||||
/* Stop button — 44px touch target */
|
||||
/* State label — smaller on phone, stays first */
|
||||
.respawn-state {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
}
|
||||
|
||||
/* Confidence badge — compact */
|
||||
.respawn-banner .detection-confidence {
|
||||
font-size: 0.5rem;
|
||||
padding: 0.02rem 0.2rem;
|
||||
}
|
||||
|
||||
/* Cycles — compact */
|
||||
.respawn-cycles {
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
/* Run timer — compact */
|
||||
.respawn-timer {
|
||||
font-size: 0.55rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
/* Tokens — compact, truncate if long */
|
||||
.respawn-tokens {
|
||||
font-size: 0.55rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
/* Spinner icon — smaller on phone */
|
||||
.respawn-indicator {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
/* Stop button — 36px touch target, pushed right */
|
||||
.respawn-banner .btn-icon-only {
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
@@ -1247,26 +1350,50 @@ html.mobile-init .file-browser-panel {
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Action log — horizontal strip below status, border on top instead of left */
|
||||
.respawn-action-log {
|
||||
border-left: none;
|
||||
border-top: 1px solid rgba(34, 197, 94, 0.2);
|
||||
padding-left: 0;
|
||||
padding-top: 0.25rem;
|
||||
max-height: 2em;
|
||||
font-size: 0.6rem;
|
||||
/* Row2: tighter spacing, smaller text */
|
||||
.respawn-status-row2 {
|
||||
font-size: 0.55rem;
|
||||
gap: 0.3rem;
|
||||
min-height: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Countdown timers — tighter on mobile */
|
||||
/* Detection badges — smaller on phone */
|
||||
.respawn-banner .detection-hook,
|
||||
.respawn-banner .detection-ai-check,
|
||||
.respawn-banner .detection-status {
|
||||
font-size: 0.55rem;
|
||||
padding: 0.02rem 0.2rem;
|
||||
}
|
||||
|
||||
/* Countdown timers — inline, no progress bars */
|
||||
.respawn-countdown-timers {
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.respawn-countdown-timer {
|
||||
font-size: 0.5rem;
|
||||
padding: 0.02rem 0.2rem;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
/* Hide progress bars on phone — values are enough */
|
||||
.respawn-countdown-timer .respawn-timer-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Action log — single-line strip below status, show latest entry only */
|
||||
.respawn-action-log {
|
||||
border-left: none;
|
||||
border-top: 1px solid rgba(34, 197, 94, 0.15);
|
||||
padding-left: 0;
|
||||
padding-top: 0.15rem;
|
||||
max-height: 1.4em;
|
||||
font-size: 0.55rem;
|
||||
padding: 0.05rem 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Session Options Modal: Respawn Tab Mobile Optimizations ---- */
|
||||
@@ -1292,28 +1419,27 @@ html.mobile-init .file-browser-panel {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* Duration preset buttons — single row, all 7 items */
|
||||
/* Duration preset buttons — grid layout, 4 columns for even spacing */
|
||||
.duration-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.duration-preset-btn {
|
||||
min-height: 28px;
|
||||
padding: 0.2rem 0.4rem;
|
||||
min-height: 32px;
|
||||
padding: 0.2rem 0.25rem;
|
||||
font-size: 0.65rem;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Custom duration — inline with other presets */
|
||||
/* Custom duration — spans full row below */
|
||||
.duration-custom {
|
||||
flex-wrap: wrap;
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex: 1 1 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.duration-custom .duration-preset-btn {
|
||||
@@ -1331,24 +1457,23 @@ html.mobile-init .file-browser-panel {
|
||||
font-size: 16px; /* Prevents iOS zoom */
|
||||
}
|
||||
|
||||
/* Preset selector row — compact inline */
|
||||
/* Preset selector row — full-width dropdown, buttons below */
|
||||
.preset-selector {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.preset-selector select {
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
grid-column: 1 / -1;
|
||||
min-height: 32px;
|
||||
font-size: 16px; /* Prevents iOS zoom */
|
||||
border-radius: 5px;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
|
||||
.preset-selector .btn {
|
||||
flex: 1;
|
||||
min-height: 28px;
|
||||
min-height: 32px;
|
||||
font-size: 0.65rem;
|
||||
border-radius: 5px;
|
||||
padding: 0.2rem 0.4rem;
|
||||
@@ -1971,7 +2096,8 @@ html.mobile-init .file-browser-panel {
|
||||
padding: 6px 8px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
@@ -1979,10 +2105,15 @@ html.mobile-init .file-browser-panel {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.keyboard-accessory-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accessory-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: #2a2a2a;
|
||||
@@ -2012,31 +2143,33 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
.accessory-btn-arrow {
|
||||
padding: 6px 10px;
|
||||
background: #1e3a5f;
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
color: #93c5fd;
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow:active {
|
||||
background: #2563eb;
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss {
|
||||
padding: 8px 14px;
|
||||
background: #2a2a2a;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 6px;
|
||||
color: #e5e5e5;
|
||||
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: 22px;
|
||||
height: 22px;
|
||||
stroke-width: 3;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss:active {
|
||||
background: #3a3a3a;
|
||||
background: #3d5f85;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
|
||||
@@ -14,6 +14,13 @@
|
||||
*/
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
_addActivityEntry(agentId, entry, maxSize = 50) {
|
||||
const activity = this.subagentActivity.get(agentId) || [];
|
||||
activity.push(entry);
|
||||
if (activity.length > maxSize) activity.shift();
|
||||
this.subagentActivity.set(agentId, activity);
|
||||
},
|
||||
|
||||
// Tasks
|
||||
_onTaskCreated(data) {
|
||||
this.renderSessionTabs();
|
||||
@@ -106,15 +113,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Notify about new subagent discovery
|
||||
const parentId = this.subagentParentMap.get(data.agentId);
|
||||
const parentSession = parentId ? this.sessions.get(parentId) : null;
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'info',
|
||||
category: 'subagent-spawn',
|
||||
sessionId: parentId || data.sessionId,
|
||||
sessionName: parentSession?.name || parentId || data.sessionId,
|
||||
title: 'Subagent Spawned',
|
||||
message: data.description || 'New background agent started',
|
||||
});
|
||||
this._notifySession(parentId || data.sessionId, 'info', 'subagent-spawn', 'Subagent Spawned', data.description || 'New background agent started');
|
||||
},
|
||||
|
||||
_onSubagentUpdated(data) {
|
||||
@@ -135,10 +134,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_onSubagentToolCall(data) {
|
||||
const activity = this.subagentActivity.get(data.agentId) || [];
|
||||
activity.push({ type: 'tool', ...data });
|
||||
if (activity.length > 50) activity.shift(); // Keep last 50 entries
|
||||
this.subagentActivity.set(data.agentId, activity);
|
||||
this._addActivityEntry(data.agentId, { type: 'tool', ...data });
|
||||
if (this.activeSubagentId === data.agentId) {
|
||||
this.renderSubagentDetail();
|
||||
}
|
||||
@@ -150,10 +146,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_onSubagentProgress(data) {
|
||||
const activity = this.subagentActivity.get(data.agentId) || [];
|
||||
activity.push({ type: 'progress', ...data });
|
||||
if (activity.length > 50) activity.shift();
|
||||
this.subagentActivity.set(data.agentId, activity);
|
||||
this._addActivityEntry(data.agentId, { type: 'progress', ...data });
|
||||
if (this.activeSubagentId === data.agentId) {
|
||||
this.renderSubagentDetail();
|
||||
}
|
||||
@@ -164,10 +157,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_onSubagentMessage(data) {
|
||||
const activity = this.subagentActivity.get(data.agentId) || [];
|
||||
activity.push({ type: 'message', ...data });
|
||||
if (activity.length > 50) activity.shift();
|
||||
this.subagentActivity.set(data.agentId, activity);
|
||||
this._addActivityEntry(data.agentId, { type: 'message', ...data });
|
||||
if (this.activeSubagentId === data.agentId) {
|
||||
this.renderSubagentDetail();
|
||||
}
|
||||
@@ -190,10 +180,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Add to activity stream
|
||||
const activity = this.subagentActivity.get(data.agentId) || [];
|
||||
activity.push({ type: 'tool_result', ...data });
|
||||
if (activity.length > 50) activity.shift();
|
||||
this.subagentActivity.set(data.agentId, activity);
|
||||
this._addActivityEntry(data.agentId, { type: 'tool_result', ...data });
|
||||
|
||||
if (this.activeSubagentId === data.agentId) {
|
||||
this.renderSubagentDetail();
|
||||
@@ -224,15 +211,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Notify about subagent completion
|
||||
const parentId = this.subagentParentMap.get(data.agentId);
|
||||
const parentSession = parentId ? this.sessions.get(parentId) : null;
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'info',
|
||||
category: 'subagent-complete',
|
||||
sessionId: parentId || existing?.sessionId || data.sessionId,
|
||||
sessionName: parentSession?.name || parentId || data.sessionId,
|
||||
title: 'Subagent Completed',
|
||||
message: existing?.description || data.description || 'Background agent finished',
|
||||
});
|
||||
this._notifySession(parentId || existing?.sessionId || data.sessionId, 'info', 'subagent-complete', 'Subagent Completed', existing?.description || data.description || 'Background agent finished');
|
||||
|
||||
// Clean up activity/tool data for completed agents after 5 minutes
|
||||
// This prevents memory leaks from long-running sessions with many subagents
|
||||
@@ -2339,12 +2318,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
|
||||
|
||||
const downloadBtn = !isDir
|
||||
? `<a class="file-tree-download" href="/api/sessions/${this.activeSessionId}/file-raw?path=${encodeURIComponent(node.path)}&download=true" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
: '';
|
||||
|
||||
html.push(`
|
||||
<div class="file-tree-item${hiddenClass}" data-path="${escapeHtml(node.path)}" data-type="${node.type}" data-depth="${depth}">
|
||||
${expandIcon}
|
||||
<span class="file-tree-icon">${icon}</span>
|
||||
<span class="${nameClass}">${escapeHtml(node.name)}</span>
|
||||
${sizeStr}
|
||||
${downloadBtn}
|
||||
</div>
|
||||
`);
|
||||
|
||||
|
||||
@@ -45,15 +45,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.updateRalphState(data.sessionId, existing);
|
||||
}
|
||||
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'warning',
|
||||
category: 'ralph-complete',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || this.getShortId(data.sessionId),
|
||||
title: 'Loop Complete',
|
||||
message: `Completion: ${data.phrase || 'unknown'}`,
|
||||
});
|
||||
this._notifySession(data.sessionId, 'warning', 'ralph-complete', 'Loop Complete', `Completion: ${data.phrase || 'unknown'}`);
|
||||
},
|
||||
|
||||
_onRalphStatusUpdate(data) {
|
||||
@@ -68,28 +60,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.updateRalphState(data.sessionId, { circuitBreaker: data.status });
|
||||
// Notify if circuit breaker opens
|
||||
if (data.status.state === 'OPEN') {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'critical',
|
||||
category: 'circuit-breaker',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || this.getShortId(data.sessionId),
|
||||
title: 'Circuit Breaker Open',
|
||||
message: data.status.reason || 'Loop stuck - no progress detected',
|
||||
});
|
||||
this._notifySession(data.sessionId, 'critical', 'circuit-breaker', 'Circuit Breaker Open', data.status.reason || 'Loop stuck - no progress detected');
|
||||
}
|
||||
},
|
||||
|
||||
_onExitGateMet(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'warning',
|
||||
category: 'exit-gate',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || this.getShortId(data.sessionId),
|
||||
title: 'Exit Gate Met',
|
||||
message: `Loop ready to exit (indicators: ${data.completionIndicators})`,
|
||||
});
|
||||
this._notifySession(data.sessionId, 'warning', 'exit-gate', 'Exit Gate Met', `Loop ready to exit (indicators: ${data.completionIndicators})`);
|
||||
},
|
||||
|
||||
// Bash tools
|
||||
|
||||
@@ -44,21 +44,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_onRespawnBlocked(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
const reasonMap = {
|
||||
circuit_breaker_open: 'Circuit Breaker Open',
|
||||
exit_signal: 'Exit Signal Detected',
|
||||
status_blocked: 'Claude Reported BLOCKED',
|
||||
};
|
||||
const title = reasonMap[data.reason] || 'Respawn Blocked';
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'critical',
|
||||
category: 'respawn-blocked',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || this.getShortId(data.sessionId),
|
||||
title,
|
||||
message: data.details,
|
||||
});
|
||||
this._notifySession(data.sessionId, 'critical', 'respawn-blocked', title, data.details);
|
||||
// Update respawn panel to show blocked state
|
||||
if (data.sessionId === this.activeSessionId) {
|
||||
const stateEl = document.getElementById('respawnStateLabel');
|
||||
@@ -71,14 +63,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
_onRespawnAutoAcceptSent(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'info',
|
||||
category: 'auto-accept',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || this.getShortId(data.sessionId),
|
||||
title: 'Plan Accepted',
|
||||
message: `Accepted plan mode for ${session?.name || 'session'}`,
|
||||
});
|
||||
this._notifySession(data.sessionId, 'info', 'auto-accept', 'Plan Accepted', `Accepted plan mode for ${session?.name || 'session'}`);
|
||||
},
|
||||
|
||||
_onRespawnDetectionUpdate(data) {
|
||||
@@ -144,15 +129,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_onRespawnError(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'critical',
|
||||
category: 'session-error',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || data.sessionId,
|
||||
title: 'Respawn Error',
|
||||
message: data.error || data.message || 'Respawn encountered an error',
|
||||
});
|
||||
this._notifySession(data.sessionId, 'critical', 'session-error', 'Respawn Error', data.error || data.message || 'Respawn encountered an error');
|
||||
},
|
||||
|
||||
_onRespawnActionLog(data) {
|
||||
|
||||
@@ -298,7 +298,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Find the highest existing w-number for THIS case to avoid duplicates
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^w(\d+)-(.+)$/);
|
||||
const match = session.name && session.name.match(/^w(\d+)-([a-zA-Z0-9_-]+)/);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) {
|
||||
@@ -324,6 +324,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
envOverrides.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
|
||||
}
|
||||
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
|
||||
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
|
||||
const modelOverride = useOpus1m ? 'opus[1m]' : '';
|
||||
|
||||
// Step 1: Create all sessions in parallel
|
||||
this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`);
|
||||
@@ -331,7 +333,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, name, ...(hasEnvOverrides ? { envOverrides } : {}) })
|
||||
body: JSON.stringify({
|
||||
workingDir, name,
|
||||
...(hasEnvOverrides ? { envOverrides } : {}),
|
||||
...(modelOverride !== undefined ? { modelOverride } : {}),
|
||||
})
|
||||
}).then(r => r.json())
|
||||
);
|
||||
const createResults = await Promise.all(createPromises);
|
||||
@@ -426,7 +432,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Find the highest existing s-number for THIS case to avoid duplicates
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^s(\d+)-(.+)$/);
|
||||
const match = session.name && session.name.match(/^s(\d+)-([a-zA-Z0-9_-]+)/);
|
||||
if (match && match[2] === caseName) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) {
|
||||
@@ -590,8 +596,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
|
||||
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
|
||||
|
||||
// Populate session name input
|
||||
document.getElementById('modalSessionName').value = session.name || '';
|
||||
// Populate session name input with prefix/suffix split
|
||||
const _modalParsed = parseSessionPrefix(session.name);
|
||||
const _prefixEl = document.getElementById('modalSessionPrefix');
|
||||
if (_modalParsed) {
|
||||
_prefixEl.textContent = _modalParsed.prefix + ': ';
|
||||
_prefixEl.style.display = '';
|
||||
document.getElementById('modalSessionName').value = _modalParsed.suffix;
|
||||
document.getElementById('modalSessionName').placeholder = 'Add description...';
|
||||
} else {
|
||||
_prefixEl.style.display = 'none';
|
||||
_prefixEl.textContent = '';
|
||||
document.getElementById('modalSessionName').value = session.name || '';
|
||||
document.getElementById('modalSessionName').placeholder = 'Auto (directory name)';
|
||||
}
|
||||
|
||||
// Initialize color picker with current session color
|
||||
const currentColor = session.color || 'default';
|
||||
@@ -638,7 +656,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
async saveSessionName() {
|
||||
if (!this.editingSessionId) return;
|
||||
const name = document.getElementById('modalSessionName').value.trim();
|
||||
const session = this.sessions.get(this.editingSessionId);
|
||||
const parsed = session ? parseSessionPrefix(session.name) : null;
|
||||
const inputVal = document.getElementById('modalSessionName').value.trim();
|
||||
let name;
|
||||
if (parsed) {
|
||||
name = parsed.prefix + (inputVal ? ': ' + inputVal : '');
|
||||
} else {
|
||||
name = inputVal;
|
||||
}
|
||||
try {
|
||||
await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name });
|
||||
} catch (err) {
|
||||
@@ -858,29 +884,46 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!tabName) return;
|
||||
|
||||
const currentName = this.getSessionName(session);
|
||||
const parsed = parseSessionPrefix(session.name);
|
||||
const originalContent = tabName.textContent;
|
||||
tabName.textContent = '';
|
||||
tabName.innerHTML = '';
|
||||
|
||||
// If prefix detected, show it as non-editable label
|
||||
if (parsed) {
|
||||
const prefixLabel = document.createElement('span');
|
||||
prefixLabel.textContent = parsed.prefix + ': ';
|
||||
prefixLabel.style.cssText = 'color: var(--text-muted); font-size: 0.75rem; white-space: nowrap;';
|
||||
tabName.appendChild(prefixLabel);
|
||||
}
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = session.name || '';
|
||||
input.placeholder = currentName;
|
||||
input.value = parsed ? parsed.suffix : (session.name || '');
|
||||
input.placeholder = parsed ? 'Add description...' : currentName;
|
||||
input.className = 'tab-rename-input';
|
||||
input.style.cssText = 'width: 80px; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;';
|
||||
|
||||
const originalContent = tabName.textContent;
|
||||
tabName.textContent = '';
|
||||
tabName.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const finishRename = async () => {
|
||||
const newName = input.value.trim();
|
||||
tabName.textContent = newName || originalContent;
|
||||
const suffix = input.value.trim();
|
||||
let fullName;
|
||||
if (parsed) {
|
||||
fullName = parsed.prefix + (suffix ? ': ' + suffix : '');
|
||||
} else {
|
||||
fullName = suffix;
|
||||
}
|
||||
tabName.textContent = fullName || originalContent;
|
||||
|
||||
if (newName && newName !== session.name) {
|
||||
if (fullName !== session.name) {
|
||||
try {
|
||||
await fetch(`/api/sessions/${sessionId}/name`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName })
|
||||
body: JSON.stringify({ name: fullName })
|
||||
});
|
||||
} catch (err) {
|
||||
tabName.textContent = originalContent;
|
||||
@@ -913,6 +956,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const settings = this.getCaseSettings(caseName);
|
||||
document.getElementById('caseAgentTeams').checked = settings.agentTeams;
|
||||
document.getElementById('caseOpusContext1m').checked = settings.opusContext1m;
|
||||
popover.classList.remove('hidden');
|
||||
|
||||
// Close on outside click (one-shot listener)
|
||||
@@ -934,7 +978,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const stored = localStorage.getItem('caseSettings_' + caseName);
|
||||
if (stored) return JSON.parse(stored);
|
||||
} catch { /* ignore */ }
|
||||
return { agentTeams: false };
|
||||
return { agentTeams: false, opusContext1m: true };
|
||||
},
|
||||
|
||||
saveCaseSettings(caseName, settings) {
|
||||
@@ -945,10 +989,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const settings = this.getCaseSettings(caseName);
|
||||
settings.agentTeams = document.getElementById('caseAgentTeams').checked;
|
||||
settings.opusContext1m = document.getElementById('caseOpusContext1m').checked;
|
||||
this.saveCaseSettings(caseName, settings);
|
||||
// Sync mobile checkbox
|
||||
// Sync mobile checkboxes
|
||||
const mobileCheckbox = document.getElementById('caseAgentTeamsMobile');
|
||||
if (mobileCheckbox) mobileCheckbox.checked = settings.agentTeams;
|
||||
const mobileOpusCheckbox = document.getElementById('caseOpusContext1mMobile');
|
||||
if (mobileOpusCheckbox) mobileOpusCheckbox.checked = settings.opusContext1m;
|
||||
},
|
||||
|
||||
toggleCaseSettingsMobile() {
|
||||
@@ -957,6 +1004,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const settings = this.getCaseSettings(caseName);
|
||||
document.getElementById('caseAgentTeamsMobile').checked = settings.agentTeams;
|
||||
document.getElementById('caseOpusContext1mMobile').checked = settings.opusContext1m;
|
||||
popover.classList.remove('hidden');
|
||||
|
||||
const closeHandler = (e) => {
|
||||
@@ -975,10 +1023,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const settings = this.getCaseSettings(caseName);
|
||||
settings.agentTeams = document.getElementById('caseAgentTeamsMobile').checked;
|
||||
settings.opusContext1m = document.getElementById('caseOpusContext1mMobile').checked;
|
||||
this.saveCaseSettings(caseName, settings);
|
||||
// Sync desktop checkbox
|
||||
// Sync desktop checkboxes
|
||||
const desktopCheckbox = document.getElementById('caseAgentTeams');
|
||||
if (desktopCheckbox) desktopCheckbox.checked = settings.agentTeams;
|
||||
const desktopOpusCheckbox = document.getElementById('caseOpusContext1m');
|
||||
if (desktopOpusCheckbox) desktopOpusCheckbox.checked = settings.opusContext1m;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1024,13 +1075,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
modal.querySelectorAll('.modal-tab-content').forEach(content => {
|
||||
content.classList.toggle('hidden', content.id !== tabName);
|
||||
});
|
||||
// Update submit button text
|
||||
// Update submit button (hide for manage tab)
|
||||
const submitBtn = document.getElementById('caseModalSubmit');
|
||||
submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link';
|
||||
if (tabName === 'case-manage') {
|
||||
submitBtn.style.display = 'none';
|
||||
this.renderCaseManageList();
|
||||
} else {
|
||||
submitBtn.style.display = '';
|
||||
submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link';
|
||||
}
|
||||
// Focus appropriate input
|
||||
if (tabName === 'case-create') {
|
||||
document.getElementById('newCaseName').focus();
|
||||
} else {
|
||||
} else if (tabName === 'case-link') {
|
||||
document.getElementById('linkCaseName').focus();
|
||||
}
|
||||
},
|
||||
@@ -1138,6 +1195,107 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Case Management (reorder + delete)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
renderCaseManageList() {
|
||||
const container = document.getElementById('caseManageList');
|
||||
const cases = this.cases || [];
|
||||
if (cases.length === 0) {
|
||||
container.innerHTML = '<div class="form-hint" style="text-align: center; padding: 2rem 0;">No cases yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
cases.forEach((c, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === cases.length - 1;
|
||||
const pathDisplay = c.path ? c.path.replace(/^\/Users\/[^/]+/, '~') : '';
|
||||
html += `
|
||||
<div class="case-manage-item" data-case="${escapeHtml(c.name)}">
|
||||
<div class="case-manage-info">
|
||||
<span class="case-manage-name">${escapeHtml(c.name)}</span>
|
||||
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
|
||||
</div>
|
||||
<div class="case-manage-actions">
|
||||
<button class="case-manage-btn" onclick="app.moveCaseUp('${escapeHtml(c.name)}')"
|
||||
title="Move up" ${isFirst ? 'disabled' : ''}>▲</button>
|
||||
<button class="case-manage-btn" onclick="app.moveCaseDown('${escapeHtml(c.name)}')"
|
||||
title="Move down" ${isLast ? 'disabled' : ''}>▼</button>
|
||||
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase('${escapeHtml(c.name)}')"
|
||||
title="Delete case">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
},
|
||||
|
||||
async moveCaseUp(name) {
|
||||
const cases = this.cases || [];
|
||||
const idx = cases.findIndex(c => c.name === name);
|
||||
if (idx <= 0) return;
|
||||
// Swap positions (immutable)
|
||||
const reordered = [...cases];
|
||||
[reordered[idx - 1], reordered[idx]] = [reordered[idx], reordered[idx - 1]];
|
||||
this.cases = reordered;
|
||||
this.renderCaseManageList();
|
||||
await this.saveCaseOrder(reordered.map(c => c.name));
|
||||
},
|
||||
|
||||
async moveCaseDown(name) {
|
||||
const cases = this.cases || [];
|
||||
const idx = cases.findIndex(c => c.name === name);
|
||||
if (idx < 0 || idx >= cases.length - 1) return;
|
||||
const reordered = [...cases];
|
||||
[reordered[idx], reordered[idx + 1]] = [reordered[idx + 1], reordered[idx]];
|
||||
this.cases = reordered;
|
||||
this.renderCaseManageList();
|
||||
await this.saveCaseOrder(reordered.map(c => c.name));
|
||||
},
|
||||
|
||||
async deleteCase(name) {
|
||||
if (!confirm(`Delete case "${name}"? Linked cases will only be unlinked (folder preserved). Created cases will be permanently deleted.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/cases/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.showToast(`Case "${name}" ${data.data?.type === 'unlinked' ? 'unlinked' : 'deleted'}`, 'success');
|
||||
// Remove from current list and refresh
|
||||
this.cases = (this.cases || []).filter(c => c.name !== name);
|
||||
this.renderCaseManageList();
|
||||
// Refresh the dropdown
|
||||
const select = document.getElementById('quickStartCase');
|
||||
const currentCase = select.value;
|
||||
await this.loadQuickStartCases(currentCase === name ? null : currentCase);
|
||||
} else {
|
||||
this.showToast(data.error || 'Failed to delete case', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
this.showToast('Failed to delete case: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async saveCaseOrder(order) {
|
||||
try {
|
||||
await fetch('/api/cases/order', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order })
|
||||
});
|
||||
// Refresh dropdown to reflect new order
|
||||
const select = document.getElementById('quickStartCase');
|
||||
const currentCase = select.value;
|
||||
await this.loadQuickStartCases(currentCase);
|
||||
} catch (err) {
|
||||
this.showToast('Failed to save case order: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Mobile Case Picker
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1167,6 +1325,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
</svg>
|
||||
</span>
|
||||
<span class="mobile-case-item-name">${escapeHtml(c.name)}</span>
|
||||
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile('${escapeHtml(c.name)}')" title="Delete">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="mobile-case-item-check">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
@@ -1212,6 +1375,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCaseMobile(name) {
|
||||
if (!confirm(`Delete case "${name}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/cases/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.showToast(`Case "${name}" ${data.data?.type === 'unlinked' ? 'unlinked' : 'deleted'}`, 'success');
|
||||
this.cases = (this.cases || []).filter(c => c.name !== name);
|
||||
// Refresh mobile picker and dropdown
|
||||
this.closeMobileCasePicker();
|
||||
await this.loadQuickStartCases();
|
||||
} else {
|
||||
this.showToast(data.error || 'Failed to delete case', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
this.showToast('Failed to delete case: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
showCreateCaseFromMobile() {
|
||||
// Close mobile picker first
|
||||
this.closeMobileCasePicker();
|
||||
|
||||
@@ -14,92 +14,46 @@
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// Hooks (Claude Code hook events)
|
||||
_onHookIdlePrompt(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
// Always track pending hook - alert will show when switching away from session
|
||||
if (data.sessionId) {
|
||||
this.setPendingHook(data.sessionId, 'idle_prompt');
|
||||
}
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'warning',
|
||||
category: 'hook-idle',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || data.sessionId,
|
||||
title: 'Waiting for Input',
|
||||
message: data.message || 'Claude is idle and waiting for a prompt',
|
||||
});
|
||||
this._notifySession(data.sessionId, 'warning', 'hook-idle', 'Waiting for Input', data.message || 'Claude is idle and waiting for a prompt');
|
||||
},
|
||||
|
||||
_onHookPermissionPrompt(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
// Always track pending hook - action alerts need user interaction to clear
|
||||
if (data.sessionId) {
|
||||
this.setPendingHook(data.sessionId, 'permission_prompt');
|
||||
}
|
||||
const toolInfo = data.tool ? `${data.tool}${data.command ? ': ' + data.command : data.file ? ': ' + data.file : ''}` : '';
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'critical',
|
||||
category: 'hook-permission',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || data.sessionId,
|
||||
title: 'Permission Required',
|
||||
message: toolInfo || 'Claude needs tool approval to continue',
|
||||
});
|
||||
this._notifySession(data.sessionId, 'critical', 'hook-permission', 'Permission Required', toolInfo || 'Claude needs tool approval to continue');
|
||||
},
|
||||
|
||||
_onHookElicitationDialog(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
// Always track pending hook - action alerts need user interaction to clear
|
||||
if (data.sessionId) {
|
||||
this.setPendingHook(data.sessionId, 'elicitation_dialog');
|
||||
}
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'critical',
|
||||
category: 'hook-elicitation',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || data.sessionId,
|
||||
title: 'Question Asked',
|
||||
message: data.question || 'Claude is asking a question and waiting for your answer',
|
||||
});
|
||||
this._notifySession(data.sessionId, 'critical', 'hook-elicitation', 'Question Asked', data.question || 'Claude is asking a question and waiting for your answer');
|
||||
},
|
||||
|
||||
_onHookStop(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
// Clear all pending hooks when Claude finishes responding
|
||||
if (data.sessionId) {
|
||||
this.clearPendingHooks(data.sessionId);
|
||||
}
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'info',
|
||||
category: 'hook-stop',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || data.sessionId,
|
||||
title: 'Response Complete',
|
||||
message: data.reason || 'Claude has finished responding',
|
||||
});
|
||||
this._notifySession(data.sessionId, 'info', 'hook-stop', 'Response Complete', data.reason || 'Claude has finished responding');
|
||||
},
|
||||
|
||||
_onHookTeammateIdle(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'warning',
|
||||
category: 'hook-teammate-idle',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || data.sessionId,
|
||||
title: 'Teammate Idle',
|
||||
message: `A teammate is idle in ${session?.name || data.sessionId}`,
|
||||
});
|
||||
this._notifySession(data.sessionId, 'warning', 'hook-teammate-idle', 'Teammate Idle', `A teammate is idle in ${session?.name || data.sessionId}`);
|
||||
},
|
||||
|
||||
_onHookTaskCompleted(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
this.notificationManager?.notify({
|
||||
urgency: 'info',
|
||||
category: 'hook-task-completed',
|
||||
sessionId: data.sessionId,
|
||||
sessionName: session?.name || data.sessionId,
|
||||
title: 'Task Completed',
|
||||
message: `A team task completed in ${session?.name || data.sessionId}`,
|
||||
});
|
||||
this._notifySession(data.sessionId, 'info', 'hook-task-completed', 'Task Completed', `A team task completed in ${session?.name || data.sessionId}`);
|
||||
},
|
||||
|
||||
|
||||
@@ -365,6 +319,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.loadTunnelStatus();
|
||||
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
// Claude CLI settings
|
||||
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
||||
@@ -378,6 +333,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
};
|
||||
// Claude Permissions settings
|
||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
|
||||
// CPU Priority settings
|
||||
const niceSettings = settings.nice || {};
|
||||
document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false;
|
||||
@@ -517,16 +473,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
_updateTunnelUrlDisplay(url) {
|
||||
const row = document.getElementById('tunnelUrlRow');
|
||||
const display = document.getElementById('tunnelUrlDisplay');
|
||||
_updateTunnelUrlRow(rowId, displayId, url, suffix = '') {
|
||||
const row = document.getElementById(rowId);
|
||||
const display = document.getElementById(displayId);
|
||||
if (!row || !display) return;
|
||||
if (url) {
|
||||
const fullUrl = url + suffix;
|
||||
row.style.display = '';
|
||||
display.textContent = url;
|
||||
display.textContent = fullUrl;
|
||||
display.onclick = () => {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
this.showToast('Tunnel URL copied', 'success');
|
||||
navigator.clipboard.writeText(fullUrl).then(() => {
|
||||
this.showToast(`${suffix ? 'Upload' : 'Tunnel'} URL copied`, 'success');
|
||||
});
|
||||
};
|
||||
} else {
|
||||
@@ -534,24 +491,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
display.textContent = '';
|
||||
display.onclick = null;
|
||||
}
|
||||
// Upload URL row
|
||||
const uploadRow = document.getElementById('tunnelUploadUrlRow');
|
||||
const uploadDisplay = document.getElementById('tunnelUploadUrlDisplay');
|
||||
if (!uploadRow || !uploadDisplay) return;
|
||||
if (url) {
|
||||
const uploadUrl = url + '/upload.html';
|
||||
uploadRow.style.display = '';
|
||||
uploadDisplay.textContent = uploadUrl;
|
||||
uploadDisplay.onclick = () => {
|
||||
navigator.clipboard.writeText(uploadUrl).then(() => {
|
||||
this.showToast('Upload URL copied', 'success');
|
||||
});
|
||||
};
|
||||
} else {
|
||||
uploadRow.style.display = 'none';
|
||||
uploadDisplay.textContent = '';
|
||||
uploadDisplay.onclick = null;
|
||||
}
|
||||
},
|
||||
|
||||
_updateTunnelUrlDisplay(url) {
|
||||
this._updateTunnelUrlRow('tunnelUrlRow', 'tunnelUrlDisplay', url);
|
||||
this._updateTunnelUrlRow('tunnelUploadUrlRow', 'tunnelUploadUrlDisplay', url, '/upload.html');
|
||||
},
|
||||
|
||||
showTunnelQR() {
|
||||
@@ -1182,12 +1126,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
||||
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||
// Claude CLI settings
|
||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
||||
// Claude Permissions settings
|
||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
||||
// CPU Priority settings
|
||||
nice: {
|
||||
enabled: document.getElementById('appSettingsNiceEnabled').checked,
|
||||
@@ -1301,9 +1247,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Apply CJK input visibility immediately
|
||||
this._updateCjkInputState();
|
||||
|
||||
// Apply keyboard bar mode
|
||||
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
|
||||
|
||||
// Save to server (includes notification prefs for cross-browser persistence)
|
||||
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
|
||||
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, ...serverSettings } = settings;
|
||||
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, ...serverSettings } = settings;
|
||||
try {
|
||||
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
|
||||
|
||||
@@ -1334,7 +1283,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Default model
|
||||
const defaultModelEl = document.getElementById('appSettingsDefaultModel');
|
||||
if (defaultModelEl) {
|
||||
defaultModelEl.value = config.defaultModel || 'opus';
|
||||
defaultModelEl.value = config.defaultModel || '';
|
||||
}
|
||||
// Show recommendations
|
||||
const showRecsEl = document.getElementById('appSettingsShowModelRecommendations');
|
||||
@@ -1373,7 +1322,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (reviewEl?.value) agentTypeOverrides.review = reviewEl.value;
|
||||
|
||||
const config = {
|
||||
defaultModel: defaultModelEl?.value || 'opus',
|
||||
defaultModel: defaultModelEl?.value || '',
|
||||
showRecommendations: showRecsEl?.checked ?? true,
|
||||
agentTypeOverrides,
|
||||
};
|
||||
@@ -1687,7 +1636,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const displayKeys = new Set([
|
||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
]);
|
||||
// Merge settings: non-display keys always sync from server,
|
||||
// display keys only seed from server when localStorage has no value
|
||||
|
||||
@@ -330,6 +330,15 @@ body {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-tab .tab-prefix {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.session-tab .tab-suffix {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Tab folder path — hidden by default, shown via .tabs-show-folder on container */
|
||||
.session-tab .tab-folder {
|
||||
font-size: 0.6rem;
|
||||
@@ -2224,6 +2233,24 @@ body {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.history-show-more {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-smooth), color var(--transition-smooth);
|
||||
}
|
||||
|
||||
.history-show-more:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.welcome-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
@@ -2964,6 +2991,93 @@ body {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
/* Case Management List (Manage tab) */
|
||||
.case-manage-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.case-manage-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 6px;
|
||||
transition: background var(--transition-smooth);
|
||||
}
|
||||
|
||||
.case-manage-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.case-manage-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.case-manage-name {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.case-manage-path {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.case-manage-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.case-manage-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.65rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-smooth);
|
||||
}
|
||||
|
||||
.case-manage-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.case-manage-btn:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.case-manage-btn-delete:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.toolbar-input {
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--bg-input);
|
||||
@@ -3692,6 +3806,25 @@ body {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mobile-case-item-delete {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
opacity: 0.4;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
transition: all var(--transition-smooth);
|
||||
}
|
||||
|
||||
.mobile-case-item-delete:active {
|
||||
opacity: 1;
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.mobile-case-picker-footer {
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
@@ -5379,7 +5512,12 @@ kbd {
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.header-brand {
|
||||
display: none;
|
||||
padding-right: 0.4rem;
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
.header-brand .logo {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.connection-text {
|
||||
@@ -6450,6 +6588,22 @@ kbd {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.file-tree-download {
|
||||
display: none;
|
||||
margin-left: auto;
|
||||
padding: 0 4px;
|
||||
color: #888;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-tree-item:hover .file-tree-download {
|
||||
display: inline;
|
||||
}
|
||||
.file-tree-download:hover {
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
/* File tree indent levels */
|
||||
.file-tree-item[data-depth="0"] { padding-left: 0.5rem; }
|
||||
.file-tree-item[data-depth="1"] { padding-left: 1.25rem; }
|
||||
@@ -7635,6 +7789,232 @@ kbd {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Response Viewer — native-scroll overlay for reading Claude responses
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Hidden on desktop — only shown on mobile via mobile.css override */
|
||||
.btn-response-viewer-header {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.response-viewer {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 85vh;
|
||||
background: #1a1a2e;
|
||||
border-top: 1px solid #333;
|
||||
border-radius: 12px 12px 0 0;
|
||||
z-index: 5000;
|
||||
flex-direction: column;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.25s ease-out;
|
||||
}
|
||||
|
||||
.response-viewer.visible {
|
||||
display: flex;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.response-viewer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.response-viewer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.response-viewer-more {
|
||||
background: #2a2a4a;
|
||||
border: 1px solid #444;
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.response-viewer-more:active {
|
||||
background: #3a3a5a;
|
||||
}
|
||||
|
||||
.response-viewer-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Conversation thread messages */
|
||||
.rv-message {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #2a2a3a;
|
||||
}
|
||||
|
||||
.rv-message:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.rv-role {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.rv-role-user {
|
||||
color: #5c7cfa;
|
||||
}
|
||||
|
||||
.rv-role-assistant {
|
||||
color: #51cf66;
|
||||
}
|
||||
|
||||
/* Markdown rendered content inside response viewer */
|
||||
.rv-text {
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.rv-text p {
|
||||
margin: 0 0 0.6em;
|
||||
}
|
||||
|
||||
.rv-text p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4 {
|
||||
color: #e0e0e0;
|
||||
margin: 1em 0 0.4em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.rv-text h1 { font-size: 1.3em; }
|
||||
.rv-text h2 { font-size: 1.15em; }
|
||||
.rv-text h3 { font-size: 1.05em; }
|
||||
|
||||
.rv-text code {
|
||||
background: #2a2a3e;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.rv-text pre {
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
|
||||
.rv-text pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rv-text ul, .rv-text ol {
|
||||
margin: 0.4em 0;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
|
||||
.rv-text li {
|
||||
margin-bottom: 0.2em;
|
||||
}
|
||||
|
||||
.rv-text blockquote {
|
||||
border-left: 3px solid #444;
|
||||
margin: 0.6em 0;
|
||||
padding: 0.3em 0 0.3em 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.rv-text strong {
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
.rv-text a {
|
||||
color: #5c7cfa;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rv-text table {
|
||||
border-collapse: collapse;
|
||||
margin: 0.6em 0;
|
||||
width: 100%;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.rv-text th, .rv-text td {
|
||||
border: 1px solid #333;
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rv-text th {
|
||||
background: #2a2a3e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.rv-text hr {
|
||||
border: none;
|
||||
border-top: 1px solid #333;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.response-viewer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 16px;
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.response-viewer-body:empty::after {
|
||||
content: 'No response yet';
|
||||
color: #555;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.response-viewer-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 4999;
|
||||
}
|
||||
|
||||
.response-viewer-backdrop.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
CJK IME Input
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
@@ -1,30 +1,105 @@
|
||||
/**
|
||||
* @fileoverview Service worker for Web Push notifications.
|
||||
* @fileoverview Service worker for PWA install + Web Push notifications.
|
||||
*
|
||||
* Receives push events from the Codeman server (via web-push library) and displays
|
||||
* OS-level notifications. Handles notification clicks to focus an existing Codeman
|
||||
* tab or open a new one. Supports action buttons, per-session deep linking, and
|
||||
* critical notification persistence (requireInteraction).
|
||||
* App-shell caching: on install, precaches the core UI assets so the app
|
||||
* launches instantly and works offline (or on flaky connections). Uses a
|
||||
* network-first strategy for navigation and API calls, cache-first for
|
||||
* static assets.
|
||||
*
|
||||
* Lifecycle: skipWaiting on install, claim clients on activate — ensures the latest
|
||||
* service worker takes control immediately without waiting for tab refresh.
|
||||
* Push notifications: receives push events from the Codeman server (via
|
||||
* web-push library) and displays OS-level notifications. Handles notification
|
||||
* clicks to focus an existing Codeman tab or open a new one.
|
||||
*
|
||||
* Lifecycle: skipWaiting on install, claim clients on activate -- ensures the
|
||||
* latest service worker takes control immediately without waiting for tab
|
||||
* refresh.
|
||||
*
|
||||
* @dependency None (runs in ServiceWorkerGlobalScope, isolated from page scripts)
|
||||
* @see src/push-store.ts — server-side VAPID key management and subscription CRUD
|
||||
* @see src/push-store.ts -- server-side VAPID key management and subscription CRUD
|
||||
*/
|
||||
|
||||
// Codeman Service Worker — Web Push notifications
|
||||
// This service worker receives push events from the server and displays OS-level notifications.
|
||||
// It also handles notification clicks to focus or open the Codeman tab.
|
||||
const CACHE_NAME = 'codeman-v1';
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
// Core app shell -- cached on install for instant startup
|
||||
const APP_SHELL = [
|
||||
'/',
|
||||
'/styles.css',
|
||||
'/mobile.css',
|
||||
'/constants.js',
|
||||
'/app.js',
|
||||
'/api-client.js',
|
||||
'/terminal-ui.js',
|
||||
'/session-ui.js',
|
||||
'/settings-ui.js',
|
||||
'/panels-ui.js',
|
||||
'/notification-manager.js',
|
||||
'/mobile-handlers.js',
|
||||
'/keyboard-accessory.js',
|
||||
'/voice-input.js',
|
||||
'/vendor/xterm.min.js',
|
||||
'/vendor/xterm-addon-fit.min.js',
|
||||
'/vendor/xterm-addon-unicode11.min.js',
|
||||
'/vendor/xterm-zerolag-input.js',
|
||||
'/vendor/xterm.css',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
// --- Install: precache app shell ---
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
// Use addAll but don't fail install if some assets 404 (hashed filenames)
|
||||
return Promise.allSettled(
|
||||
APP_SHELL.map((url) => cache.add(url).catch(() => {}))
|
||||
);
|
||||
})
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// --- Activate: clean old caches, claim clients ---
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||
)
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// --- Fetch: network-first with cache fallback ---
|
||||
// Network-first ensures deploys take effect immediately when online.
|
||||
// Cache is only used when the network is unavailable (offline/flaky).
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
|
||||
// Skip non-GET, WebSocket upgrades, and SSE streams
|
||||
if (request.method !== 'GET') return;
|
||||
if (request.headers.get('upgrade') === 'websocket') return;
|
||||
if (request.headers.get('accept') === 'text/event-stream') return;
|
||||
if (request.url.includes('/api/')) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response && response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(request))
|
||||
);
|
||||
});
|
||||
|
||||
// --- Push notifications ---
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
@@ -40,8 +115,8 @@ self.addEventListener('push', (event) => {
|
||||
const options = {
|
||||
body: body || '',
|
||||
tag: tag || 'codeman-default',
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
data: { sessionId, url: sessionId ? `/?session=${sessionId}` : '/' },
|
||||
renotify: true,
|
||||
requireInteraction: urgency === 'critical',
|
||||
@@ -75,7 +150,7 @@ self.addEventListener('notificationclick', (event) => {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// No existing tab — open a new one
|
||||
// No existing tab -- open a new one
|
||||
return self.clients.openWindow(targetUrl);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -77,9 +77,72 @@ Object.assign(CodemanApp.prototype, {
|
||||
// during composition, causing duplicate or garbled input.
|
||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||
if (ev.isComposing || ev.keyCode === 229) return false;
|
||||
|
||||
// Let Alt+digit pass through to browser (tab switching)
|
||||
if (ev.altKey && ev.key >= '0' && ev.key <= '9') return false;
|
||||
|
||||
// Shift+Enter / Ctrl+Enter: insert newline for multi-line input.
|
||||
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
|
||||
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
|
||||
// which the inner application recognizes as "insert newline" vs carriage return.
|
||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
|
||||
if (this.activeSessionId) {
|
||||
if (this._localEchoEnabled) {
|
||||
const text = this._localEchoOverlay?.pendingText || '';
|
||||
this._localEchoOverlay?.clear();
|
||||
this._localEchoOverlay?.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(this.activeSessionId);
|
||||
this._flushedTexts?.delete(this.activeSessionId);
|
||||
if (text) {
|
||||
this._pendingInput += text;
|
||||
flushInput();
|
||||
}
|
||||
setTimeout(() => {
|
||||
fetch(`/api/sessions/${this.activeSessionId}/send-key`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
||||
});
|
||||
}, text ? 80 : 0);
|
||||
} else {
|
||||
fetch(`/api/sessions/${this.activeSessionId}/send-key`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
||||
});
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Android virtual keyboard fix: catch non-composition input events.
|
||||
// On Android Chrome, typing symbols (e.g., "/" from Gboard's symbol keyboard)
|
||||
// sends keyCode 229 + input event WITHOUT compositionstart/end wrapping.
|
||||
// The custom key handler above returns false for keyCode 229, telling xterm
|
||||
// to ignore the keydown. This listener catches those orphaned input events.
|
||||
{
|
||||
const xtermTextarea = container.querySelector('.xterm-helper-textarea');
|
||||
if (xtermTextarea && MobileDetection.isTouchDevice()) {
|
||||
let composing = false;
|
||||
xtermTextarea.addEventListener('compositionstart', () => { composing = true; });
|
||||
xtermTextarea.addEventListener('compositionend', () => { composing = false; });
|
||||
xtermTextarea.addEventListener('input', (e) => {
|
||||
if (composing || e.isComposing) return;
|
||||
if (e.inputType !== 'insertText' || !e.data) return;
|
||||
const data = e.data;
|
||||
Promise.resolve().then(() => {
|
||||
const val = xtermTextarea.value;
|
||||
if (!val || val.trim() === '') return;
|
||||
this.terminal._core.coreService.triggerDataEvent(data, true);
|
||||
xtermTextarea.value = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// WebGL renderer for GPU-accelerated terminal rendering.
|
||||
// Previously caused "page unresponsive" crashes from synchronous GPU stalls,
|
||||
// but the 48KB/frame flush cap in flushPendingWrites() now prevents
|
||||
@@ -179,6 +242,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Accumulate sub-line pixel deltas so slow swipes still scroll
|
||||
let pixelAccum = 0;
|
||||
|
||||
let didScroll = false; // track whether touchmove fired (tap vs scroll)
|
||||
container.addEventListener(
|
||||
'touchstart',
|
||||
(ev) => {
|
||||
@@ -187,6 +251,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
velocity = 0;
|
||||
pixelAccum = 0;
|
||||
isTouching = true;
|
||||
didScroll = false;
|
||||
lastTime = 0;
|
||||
if (scrollFrame) {
|
||||
cancelAnimationFrame(scrollFrame);
|
||||
@@ -201,6 +266,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'touchmove',
|
||||
(ev) => {
|
||||
if (ev.touches.length === 1 && isTouching) {
|
||||
didScroll = true;
|
||||
const touchY = ev.touches[0].clientY;
|
||||
const delta = touchLastY - touchY; // positive = scroll down
|
||||
pixelAccum += delta;
|
||||
@@ -225,6 +291,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!scrollFrame && Math.abs(velocity) > 0.3) {
|
||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
||||
}
|
||||
// Tap (no scroll): refocus xterm's hidden textarea so keyboard input
|
||||
// routes back to the terminal. Without this, a tap on the terminal area
|
||||
// consumes the touch event but xterm's textarea never regains focus.
|
||||
if (!didScroll && this.terminal) {
|
||||
this.terminal.focus();
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
@@ -284,22 +356,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
this.flushFlickerBuffer();
|
||||
}
|
||||
// Clear viewport + scrollback for Ink-based sessions before sending SIGWINCH.
|
||||
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
|
||||
// pushing overflow into scrollback. Ink's cursor-up count is based on the
|
||||
// pre-reflow line count, so ghost renders accumulate in scrollback.
|
||||
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
|
||||
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
|
||||
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
if (
|
||||
activeResizeSession &&
|
||||
activeResizeSession.mode !== 'shell' &&
|
||||
!activeResizeSession._ended &&
|
||||
this.terminal &&
|
||||
this.isTerminalAtBottom()
|
||||
) {
|
||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||
}
|
||||
// Skip server resize while mobile keyboard is visible — sending SIGWINCH
|
||||
// causes Ink to re-render at the new row count, garbling terminal output.
|
||||
// Local fit() still runs so xterm knows the viewport size for scrolling.
|
||||
@@ -311,6 +367,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS;
|
||||
// Only send resize if dimensions actually changed
|
||||
if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) {
|
||||
// Clear viewport + scrollback ONLY when dimensions actually change.
|
||||
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
|
||||
// pushing overflow into scrollback. Ink's cursor-up count is based on the
|
||||
// pre-reflow line count, so ghost renders accumulate in scrollback.
|
||||
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
|
||||
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
|
||||
// IMPORTANT: Only clear when we're actually sending SIGWINCH (dims changed).
|
||||
// Clearing without a subsequent Ink redraw leaves the terminal blank.
|
||||
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
if (
|
||||
activeResizeSession &&
|
||||
activeResizeSession.mode !== 'shell' &&
|
||||
!activeResizeSession._ended &&
|
||||
this.terminal &&
|
||||
this.isTerminalAtBottom()
|
||||
) {
|
||||
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
|
||||
}
|
||||
this._lastResizeDims = { cols, rows };
|
||||
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
|
||||
method: 'POST',
|
||||
@@ -723,84 +797,110 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch and deduplicate history sessions (up to 2 per dir, max `limit` total).
|
||||
* @returns {Promise<Array>} deduplicated session list, sorted by lastModified desc
|
||||
* Fetch and deduplicate history sessions (up to 3 per project, sorted by date).
|
||||
* Uses projectKey for grouping because workingDir decoding is lossy.
|
||||
* @returns {Promise<Array>} deduplicated session list, most recent first
|
||||
*/
|
||||
async _fetchHistorySessions(limit = 12) {
|
||||
async _fetchHistorySessions() {
|
||||
const res = await fetch('/api/history/sessions');
|
||||
const data = await res.json();
|
||||
const sessions = data.sessions || [];
|
||||
if (sessions.length === 0) return [];
|
||||
|
||||
const byDir = new Map();
|
||||
const byProject = new Map();
|
||||
for (const s of sessions) {
|
||||
if (!byDir.has(s.workingDir)) byDir.set(s.workingDir, []);
|
||||
byDir.get(s.workingDir).push(s);
|
||||
const key = s.projectKey || s.workingDir;
|
||||
if (!byProject.has(key)) byProject.set(key, []);
|
||||
byProject.get(key).push(s);
|
||||
}
|
||||
const items = [];
|
||||
for (const [, group] of byDir) {
|
||||
items.push(...group.slice(0, 2));
|
||||
for (const [, group] of byProject) {
|
||||
items.push(...group.slice(0, 3));
|
||||
}
|
||||
items.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
|
||||
return items.slice(0, limit);
|
||||
return items;
|
||||
},
|
||||
|
||||
/** Build a single history item DOM element */
|
||||
_buildHistoryItem(s) {
|
||||
const size =
|
||||
s.sizeBytes < 1024
|
||||
? `${s.sizeBytes}B`
|
||||
: s.sizeBytes < 1048576
|
||||
? `${(s.sizeBytes / 1024).toFixed(0)}K`
|
||||
: `${(s.sizeBytes / 1048576).toFixed(1)}M`;
|
||||
const date = new Date(s.lastModified);
|
||||
const timeStr =
|
||||
date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) +
|
||||
' ' +
|
||||
date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'history-item';
|
||||
item.title = s.workingDir;
|
||||
item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
|
||||
|
||||
const textCol = document.createElement('div');
|
||||
textCol.className = 'history-item-text';
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'history-item-title';
|
||||
titleSpan.textContent = s.firstPrompt || shortDir;
|
||||
|
||||
const subtitleSpan = document.createElement('span');
|
||||
subtitleSpan.className = 'history-item-subtitle';
|
||||
subtitleSpan.textContent = shortDir;
|
||||
|
||||
textCol.append(titleSpan, subtitleSpan);
|
||||
|
||||
const metaSpan = document.createElement('span');
|
||||
metaSpan.className = 'history-item-meta';
|
||||
metaSpan.textContent = timeStr;
|
||||
|
||||
const sizeSpan = document.createElement('span');
|
||||
sizeSpan.className = 'history-item-size';
|
||||
sizeSpan.textContent = size;
|
||||
|
||||
item.append(textCol, metaSpan, sizeSpan);
|
||||
return item;
|
||||
},
|
||||
|
||||
/** Number of history items shown before "Show More" */
|
||||
_HISTORY_INITIAL_COUNT: 4,
|
||||
|
||||
async loadHistorySessions() {
|
||||
const container = document.getElementById('historySessions');
|
||||
const list = document.getElementById('historyList');
|
||||
if (!container || !list) return;
|
||||
|
||||
try {
|
||||
const display = await this._fetchHistorySessions(12);
|
||||
if (display.length === 0) {
|
||||
const allSessions = await this._fetchHistorySessions(30);
|
||||
if (allSessions.length === 0) {
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build DOM safely (no innerHTML with user data)
|
||||
list.replaceChildren();
|
||||
for (const s of display) {
|
||||
const size =
|
||||
s.sizeBytes < 1024
|
||||
? `${s.sizeBytes}B`
|
||||
: s.sizeBytes < 1048576
|
||||
? `${(s.sizeBytes / 1024).toFixed(0)}K`
|
||||
: `${(s.sizeBytes / 1048576).toFixed(1)}M`;
|
||||
const date = new Date(s.lastModified);
|
||||
const timeStr =
|
||||
date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) +
|
||||
' ' +
|
||||
date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
|
||||
const initialCount = this._HISTORY_INITIAL_COUNT;
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'history-item';
|
||||
item.title = s.workingDir;
|
||||
item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
|
||||
// Render initial items
|
||||
for (let i = 0; i < Math.min(initialCount, allSessions.length); i++) {
|
||||
list.appendChild(this._buildHistoryItem(allSessions[i]));
|
||||
}
|
||||
|
||||
const textCol = document.createElement('div');
|
||||
textCol.className = 'history-item-text';
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'history-item-title';
|
||||
titleSpan.textContent = s.firstPrompt || shortDir;
|
||||
|
||||
const subtitleSpan = document.createElement('span');
|
||||
subtitleSpan.className = 'history-item-subtitle';
|
||||
subtitleSpan.textContent = shortDir;
|
||||
|
||||
textCol.append(titleSpan, subtitleSpan);
|
||||
|
||||
const metaSpan = document.createElement('span');
|
||||
metaSpan.className = 'history-item-meta';
|
||||
metaSpan.textContent = timeStr;
|
||||
|
||||
const sizeSpan = document.createElement('span');
|
||||
sizeSpan.className = 'history-item-size';
|
||||
sizeSpan.textContent = size;
|
||||
|
||||
item.append(textCol, metaSpan, sizeSpan);
|
||||
list.appendChild(item);
|
||||
// Add "Show More" button if there are more items
|
||||
if (allSessions.length > initialCount) {
|
||||
const moreBtn = document.createElement('button');
|
||||
moreBtn.className = 'history-show-more';
|
||||
moreBtn.textContent = `Show ${allSessions.length - initialCount} more`;
|
||||
moreBtn.addEventListener('click', () => {
|
||||
for (let i = initialCount; i < allSessions.length; i++) {
|
||||
list.insertBefore(this._buildHistoryItem(allSessions[i]), moreBtn);
|
||||
}
|
||||
moreBtn.remove();
|
||||
});
|
||||
list.appendChild(moreBtn);
|
||||
}
|
||||
|
||||
container.style.display = '';
|
||||
@@ -1348,6 +1448,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this.fitAddon) this.fitAddon.fit();
|
||||
const dims = this.getTerminalDimensions();
|
||||
if (!dims) return;
|
||||
// Update _lastResizeDims so the throttledResize handler won't redundantly
|
||||
// clear the terminal for the same dimensions (which would blank the screen
|
||||
// without a subsequent Ink redraw to repaint it).
|
||||
this._lastResizeDims = { cols: dims.cols, rows: dims.rows };
|
||||
// Fast path: WebSocket resize
|
||||
if (this._wsReady && this._wsSessionId === sessionId) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* @fileoverview Respawn event wiring — pure functions that connect RespawnController
|
||||
* events to SSE broadcasts, push notifications, and run summary tracking.
|
||||
*
|
||||
* Extracted from WebServer to keep respawn-specific event plumbing separate from
|
||||
* HTTP/session concerns. Follows the same DI pattern as session-listener-wiring.ts.
|
||||
*/
|
||||
|
||||
import { Session } from '../session.js';
|
||||
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
|
||||
import type { PersistedRespawnConfig } from '../types.js';
|
||||
import type { RunSummaryTracker } from '../run-summary.js';
|
||||
import type { TerminalMultiplexer } from '../mux-interface.js';
|
||||
import type { TeamWatcher } from '../team-watcher.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
|
||||
// ============================================================================
|
||||
// Dependency Interface
|
||||
// ============================================================================
|
||||
|
||||
export interface RespawnWiringDeps {
|
||||
broadcast(event: string, data: unknown): void;
|
||||
sendPushNotifications(event: string, data: Record<string, unknown>): void;
|
||||
persistSessionState(session: Session): void;
|
||||
getSession(sessionId: string): Session | undefined;
|
||||
sessionExists(sessionId: string): boolean;
|
||||
getRunSummaryTracker(sessionId: string): RunSummaryTracker | undefined;
|
||||
getRespawnControllers(): Map<string, RespawnController>;
|
||||
getRespawnTimers(): Map<string, { timer: NodeJS.Timeout; endAt: number; startedAt: number }>;
|
||||
getPendingRespawnStarts(): Map<string, NodeJS.Timeout>;
|
||||
teamWatcher: TeamWatcher;
|
||||
serverStartTime: number;
|
||||
respawnRestoreGracePeriodMs: number;
|
||||
mux: TerminalMultiplexer;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Respawn Listener Wiring
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Wire a RespawnController's events to SSE broadcasts, push notifications,
|
||||
* and run summary tracking.
|
||||
*/
|
||||
export function wireRespawnListeners(sessionId: string, controller: RespawnController, deps: RespawnWiringDeps): void {
|
||||
// Wire team watcher for team-aware idle detection
|
||||
controller.setTeamWatcher(deps.teamWatcher);
|
||||
|
||||
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
|
||||
const getTracker = () => deps.getRunSummaryTracker(sessionId);
|
||||
|
||||
// ─── Respawn State Machine ──────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:stateChanged` — state machine transition (e.g., IDLE → DETECTING → RESPAWNING) */
|
||||
controller.on('stateChanged', (state: RespawnState, prevState: RespawnState) => {
|
||||
deps.broadcast(SseEvent.RespawnStateChanged, { sessionId, state, prevState });
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordStateChange(state, `${prevState} → ${state}`);
|
||||
});
|
||||
|
||||
// ─── Respawn Cycle Lifecycle ────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:cycleStarted` — new respawn cycle begins */
|
||||
controller.on('respawnCycleStarted', (cycleNumber: number) => {
|
||||
deps.broadcast(SseEvent.RespawnCycleStarted, { sessionId, cycleNumber });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:cycleCompleted` — respawn cycle finished */
|
||||
controller.on('respawnCycleCompleted', (cycleNumber: number) => {
|
||||
deps.broadcast(SseEvent.RespawnCycleCompleted, { sessionId, cycleNumber });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:blocked` + push notification — respawn blocked by error/circuit breaker */
|
||||
controller.on('respawnBlocked', (data: { reason: string; details: string }) => {
|
||||
deps.broadcast(SseEvent.RespawnBlocked, { sessionId, reason: data.reason, details: data.details });
|
||||
const sessionForPush = deps.getSession(sessionId);
|
||||
deps.sendPushNotifications(SseEvent.RespawnBlocked, {
|
||||
sessionId,
|
||||
sessionName: sessionForPush?.name ?? sessionId.slice(0, 8),
|
||||
reason: data.reason,
|
||||
});
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordWarning(`Respawn blocked: ${data.reason}`, data.details);
|
||||
});
|
||||
|
||||
// ─── Respawn Step Progress ──────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:stepSent` — respawn step input sent (e.g., /clear, kickstart prompt) */
|
||||
controller.on('stepSent', (step: string, input: string) => {
|
||||
deps.broadcast(SseEvent.RespawnStepSent, { sessionId, step, input });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:stepCompleted` — respawn step finished */
|
||||
controller.on('stepCompleted', (step: string) => {
|
||||
deps.broadcast(SseEvent.RespawnStepCompleted, { sessionId, step });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:detectionUpdate` — idle/completion detection state changed */
|
||||
controller.on('detectionUpdate', (detection: unknown) => {
|
||||
deps.broadcast(SseEvent.RespawnDetectionUpdate, { sessionId, detection });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:autoAcceptSent` — auto-accepted a permission prompt */
|
||||
controller.on('autoAcceptSent', () => {
|
||||
deps.broadcast(SseEvent.RespawnAutoAcceptSent, { sessionId });
|
||||
});
|
||||
|
||||
// ─── AI Checker Events ──────────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:aiCheckStarted` — AI idle checker invoked */
|
||||
controller.on('aiCheckStarted', () => {
|
||||
deps.broadcast(SseEvent.RespawnAiCheckStarted, { sessionId });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:aiCheckCompleted` — AI idle check returned verdict (idle/working/stuck) */
|
||||
controller.on('aiCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
|
||||
deps.broadcast(SseEvent.RespawnAiCheckCompleted, {
|
||||
sessionId,
|
||||
verdict: result.verdict,
|
||||
reasoning: result.reasoning,
|
||||
durationMs: result.durationMs,
|
||||
});
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordAiCheckResult(result.verdict);
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:aiCheckFailed` — AI idle check errored */
|
||||
controller.on('aiCheckFailed', (error: string) => {
|
||||
deps.broadcast(SseEvent.RespawnAiCheckFailed, { sessionId, error });
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordError('AI check failed', error);
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:aiCheckCooldown` — AI check on cooldown after failure */
|
||||
controller.on('aiCheckCooldown', (active: boolean, endsAt: number | null) => {
|
||||
deps.broadcast(SseEvent.RespawnAiCheckCooldown, { sessionId, active, endsAt });
|
||||
});
|
||||
|
||||
// ─── Plan Checker Events ────────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:planCheckStarted` — AI plan completion checker invoked */
|
||||
controller.on('planCheckStarted', () => {
|
||||
deps.broadcast(SseEvent.RespawnPlanCheckStarted, { sessionId });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:planCheckCompleted` — plan check returned verdict */
|
||||
controller.on('planCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
|
||||
deps.broadcast(SseEvent.RespawnPlanCheckCompleted, {
|
||||
sessionId,
|
||||
verdict: result.verdict,
|
||||
reasoning: result.reasoning,
|
||||
durationMs: result.durationMs,
|
||||
});
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:planCheckFailed` — plan check errored */
|
||||
controller.on('planCheckFailed', (error: string) => {
|
||||
deps.broadcast(SseEvent.RespawnPlanCheckFailed, { sessionId, error });
|
||||
});
|
||||
|
||||
// ─── Timer Events (UI countdown display) ────────────────
|
||||
|
||||
/** Broadcasts `respawn:timerStarted` — countdown timer started (idle, cooldown, etc.) */
|
||||
controller.on('timerStarted', (timer) => {
|
||||
deps.broadcast(SseEvent.RespawnTimerStarted, { sessionId, timer });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:timerCancelled` — timer cancelled before expiry */
|
||||
controller.on('timerCancelled', (timerName, reason) => {
|
||||
deps.broadcast(SseEvent.RespawnTimerCancelled, { sessionId, timerName, reason });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:timerCompleted` — timer expired */
|
||||
controller.on('timerCompleted', (timerName) => {
|
||||
deps.broadcast(SseEvent.RespawnTimerCompleted, { sessionId, timerName });
|
||||
});
|
||||
|
||||
// ─── Logging & Errors ───────────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:actionLog` — respawn action logged for audit/debugging */
|
||||
controller.on('actionLog', (action) => {
|
||||
deps.broadcast(SseEvent.RespawnActionLog, { sessionId, action });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:log` — general respawn log message */
|
||||
controller.on('log', (message: string) => {
|
||||
deps.broadcast(SseEvent.RespawnLog, { sessionId, message });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:error` — respawn controller error */
|
||||
controller.on('error', (error: Error) => {
|
||||
deps.broadcast(SseEvent.RespawnError, { sessionId, error: error.message });
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordError('Respawn error', error.message);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Timed Respawn
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set up a duration-limited respawn timer that stops respawn after N minutes.
|
||||
*/
|
||||
export function setupTimedRespawn(sessionId: string, durationMinutes: number, deps: RespawnWiringDeps): void {
|
||||
const timers = deps.getRespawnTimers();
|
||||
|
||||
// Clear existing timer if any
|
||||
const existing = timers.get(sessionId);
|
||||
if (existing) {
|
||||
clearTimeout(existing.timer);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const endAt = now + durationMinutes * 60 * 1000;
|
||||
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
// Stop respawn when time is up
|
||||
const controllers = deps.getRespawnControllers();
|
||||
const controller = controllers.get(sessionId);
|
||||
if (controller) {
|
||||
controller.stop();
|
||||
controller.removeAllListeners();
|
||||
controllers.delete(sessionId);
|
||||
deps.broadcast(SseEvent.RespawnStopped, { sessionId, reason: 'duration_expired' });
|
||||
}
|
||||
timers.delete(sessionId);
|
||||
// Update persisted state (respawn no longer active)
|
||||
const session = deps.getSession(sessionId);
|
||||
if (session) {
|
||||
deps.persistSessionState(session);
|
||||
}
|
||||
},
|
||||
durationMinutes * 60 * 1000
|
||||
);
|
||||
|
||||
timers.set(sessionId, { timer, endAt, startedAt: now });
|
||||
deps.broadcast(SseEvent.RespawnTimerStarted, { sessionId, durationMinutes, endAt, startedAt: now });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Respawn Controller Restore
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Restore a RespawnController from persisted configuration.
|
||||
* Creates the controller, wires listeners, and starts after a grace period.
|
||||
*/
|
||||
export function restoreRespawnController(
|
||||
session: Session,
|
||||
config: PersistedRespawnConfig,
|
||||
source: string,
|
||||
deps: RespawnWiringDeps
|
||||
): void {
|
||||
const controller = new RespawnController(session, {
|
||||
idleTimeoutMs: config.idleTimeoutMs,
|
||||
updatePrompt: config.updatePrompt,
|
||||
interStepDelayMs: config.interStepDelayMs,
|
||||
enabled: true,
|
||||
sendClear: config.sendClear,
|
||||
sendInit: config.sendInit,
|
||||
kickstartPrompt: config.kickstartPrompt,
|
||||
completionConfirmMs: config.completionConfirmMs,
|
||||
noOutputTimeoutMs: config.noOutputTimeoutMs,
|
||||
autoAcceptPrompts: config.autoAcceptPrompts,
|
||||
autoAcceptDelayMs: config.autoAcceptDelayMs,
|
||||
aiIdleCheckEnabled: config.aiIdleCheckEnabled,
|
||||
aiIdleCheckModel: config.aiIdleCheckModel,
|
||||
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext,
|
||||
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs,
|
||||
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs,
|
||||
aiPlanCheckEnabled: config.aiPlanCheckEnabled,
|
||||
aiPlanCheckModel: config.aiPlanCheckModel,
|
||||
aiPlanCheckMaxContext: config.aiPlanCheckMaxContext,
|
||||
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs,
|
||||
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs,
|
||||
});
|
||||
|
||||
const controllers = deps.getRespawnControllers();
|
||||
controllers.set(session.id, controller);
|
||||
wireRespawnListeners(session.id, controller, deps);
|
||||
|
||||
// Calculate delay: wait until grace period after server start before starting respawn
|
||||
// This prevents false idle detection immediately after a server restart/rebuild
|
||||
const timeSinceStart = Date.now() - deps.serverStartTime;
|
||||
const delayMs = Math.max(0, deps.respawnRestoreGracePeriodMs - timeSinceStart);
|
||||
|
||||
const pendingStarts = deps.getPendingRespawnStarts();
|
||||
|
||||
if (delayMs > 0) {
|
||||
console.log(
|
||||
`[Server] Restored respawn controller for session ${session.id} from ${source} (will start in ${Math.ceil(delayMs / 1000)}s)`
|
||||
);
|
||||
const delayTimer = setTimeout(() => {
|
||||
pendingStarts.delete(session.id);
|
||||
// Verify session still exists (may have been deleted during grace period)
|
||||
if (!deps.sessionExists(session.id)) {
|
||||
console.log(`[Server] Skipping restored respawn start - session ${session.id} no longer exists`);
|
||||
return;
|
||||
}
|
||||
// Double-check controller still exists and is stopped
|
||||
const ctrl = controllers.get(session.id);
|
||||
if (ctrl && ctrl.state === 'stopped') {
|
||||
ctrl.start();
|
||||
deps.broadcast(SseEvent.RespawnStarted, { sessionId: session.id });
|
||||
console.log(`[Server] Restored respawn controller started for session ${session.id}`);
|
||||
}
|
||||
}, delayMs);
|
||||
pendingStarts.set(session.id, delayTimer);
|
||||
} else {
|
||||
// Grace period has passed, start immediately
|
||||
controller.start();
|
||||
console.log(`[Server] Restored respawn controller for session ${session.id} from ${source} (started immediately)`);
|
||||
}
|
||||
|
||||
if (config.durationMinutes && config.durationMinutes > 0) {
|
||||
setupTimedRespawn(session.id, config.durationMinutes, deps);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Respawn Config Persistence
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Save respawn config to mux for restart recovery.
|
||||
*/
|
||||
export function saveRespawnConfig(
|
||||
sessionId: string,
|
||||
config: RespawnConfig,
|
||||
mux: TerminalMultiplexer,
|
||||
durationMinutes?: number
|
||||
): void {
|
||||
const persistedConfig: PersistedRespawnConfig = {
|
||||
enabled: config.enabled,
|
||||
idleTimeoutMs: config.idleTimeoutMs,
|
||||
updatePrompt: config.updatePrompt,
|
||||
interStepDelayMs: config.interStepDelayMs,
|
||||
sendClear: config.sendClear,
|
||||
sendInit: config.sendInit,
|
||||
kickstartPrompt: config.kickstartPrompt,
|
||||
autoAcceptPrompts: config.autoAcceptPrompts,
|
||||
autoAcceptDelayMs: config.autoAcceptDelayMs,
|
||||
completionConfirmMs: config.completionConfirmMs,
|
||||
noOutputTimeoutMs: config.noOutputTimeoutMs,
|
||||
aiIdleCheckEnabled: config.aiIdleCheckEnabled,
|
||||
aiIdleCheckModel: config.aiIdleCheckModel,
|
||||
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext,
|
||||
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs,
|
||||
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs,
|
||||
aiPlanCheckEnabled: config.aiPlanCheckEnabled,
|
||||
aiPlanCheckModel: config.aiPlanCheckModel,
|
||||
aiPlanCheckMaxContext: config.aiPlanCheckMaxContext,
|
||||
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs,
|
||||
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs,
|
||||
durationMinutes,
|
||||
};
|
||||
mux.updateRespawnConfig(sessionId, persistedConfig);
|
||||
}
|
||||
@@ -185,6 +185,26 @@ export function sanitizeHookData(data: Record<string, unknown> | null | undefine
|
||||
return safeFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles a service (watcher/manager) on or off based on an enabled flag.
|
||||
* Logs start/stop to console with the given label. Runs an optional callback after starting.
|
||||
*/
|
||||
export function toggleService(
|
||||
enabled: boolean,
|
||||
service: { isRunning(): boolean; start(): void; stop(): void },
|
||||
label: string,
|
||||
onStart?: () => void
|
||||
): void {
|
||||
if (enabled && !service.isRunning()) {
|
||||
service.start();
|
||||
onStart?.();
|
||||
console.log(`${label} started via settings change`);
|
||||
} else if (!enabled && service.isRunning()) {
|
||||
service.stop();
|
||||
console.log(`${label} stopped via settings change`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-configure Ralph tracker for a session.
|
||||
*
|
||||
|
||||
@@ -11,14 +11,15 @@ import { join, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { ApiResponse, CaseInfo } from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js';
|
||||
import { CreateCaseSchema, LinkCaseSchema, CaseOrderSchema } from '../schemas.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { CASES_DIR, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
||||
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
|
||||
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
|
||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||
async function readLinkedCases(): Promise<Record<string, string>> {
|
||||
@@ -46,7 +47,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
try {
|
||||
const entries = await fs.readdir(CASES_DIR, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory()) {
|
||||
if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
|
||||
cases.push({
|
||||
name: e.name,
|
||||
path: join(CASES_DIR, e.name),
|
||||
@@ -62,7 +63,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
const linkedCases = await readLinkedCases();
|
||||
const existingNames = new Set(cases.map((c) => c.name));
|
||||
for (const [name, path] of Object.entries(linkedCases)) {
|
||||
if (!existingNames.has(name) && existsSync(path)) {
|
||||
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
|
||||
cases.push({
|
||||
name,
|
||||
path,
|
||||
@@ -71,6 +72,18 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by persisted caseOrder from settings.json
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings', {});
|
||||
const caseOrder = Array.isArray(settings.caseOrder) ? (settings.caseOrder as string[]) : [];
|
||||
if (caseOrder.length > 0) {
|
||||
const orderMap = new Map(caseOrder.map((name, idx) => [name, idx]));
|
||||
cases.sort((a, b) => {
|
||||
const ai = orderMap.get(a.name) ?? Number.MAX_SAFE_INTEGER;
|
||||
const bi = orderMap.get(b.name) ?? Number.MAX_SAFE_INTEGER;
|
||||
return ai - bi;
|
||||
});
|
||||
}
|
||||
|
||||
return cases;
|
||||
});
|
||||
|
||||
@@ -150,6 +163,68 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Delete / Unlink Case ==========
|
||||
|
||||
app.delete('/api/cases/:name', async (req): Promise<ApiResponse<{ name: string }>> => {
|
||||
const { name } = req.params as { name: string };
|
||||
|
||||
if (!validatePathWithinBase(name, CASES_DIR)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
// Check linked cases first — unlink only, don't delete the actual directory
|
||||
const linkedCases = await readLinkedCases();
|
||||
if (linkedCases[name]) {
|
||||
delete linkedCases[name];
|
||||
try {
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'unlinked' });
|
||||
return { success: true, data: { name } };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
// Case in CASES_DIR — delete the entire directory
|
||||
const casePath = join(CASES_DIR, name);
|
||||
if (!existsSync(casePath)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rm(casePath, { recursive: true, force: true });
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'deleted' });
|
||||
return { success: true, data: { name } };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Reorder Cases ==========
|
||||
|
||||
app.put('/api/cases/order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
|
||||
const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data');
|
||||
|
||||
try {
|
||||
const dir = join(homedir(), '.codeman');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const merged = { ...existing, caseOrder: order };
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||
ctx.broadcast(SseEvent.CaseOrderChanged, { order });
|
||||
return { success: true, data: { order } };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/cases/:name', async (req) => {
|
||||
const { name } = req.params as { name: string };
|
||||
|
||||
|
||||
@@ -239,7 +239,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
// Serve raw file content (for images/binary files)
|
||||
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const { path: filePath, download } = req.query as { path?: string; download?: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
if (!filePath) {
|
||||
@@ -292,6 +292,18 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
||||
};
|
||||
|
||||
const content = await fs.readFile(resolvedPath);
|
||||
if (download === 'true') {
|
||||
const rawBasename = filePath!.split('/').pop() || 'download';
|
||||
// Sanitize filename for Content-Disposition header (prevent header injection)
|
||||
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${basename}"`,
|
||||
'Content-Length': content.length,
|
||||
});
|
||||
reply.raw.end(content);
|
||||
return;
|
||||
}
|
||||
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
||||
reply.send(content);
|
||||
} catch (err) {
|
||||
|
||||
@@ -39,43 +39,37 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
||||
return loop;
|
||||
}
|
||||
|
||||
const EVENT_MAP: [string, (typeof SseEvent)[keyof typeof SseEvent], string[]][] = [
|
||||
['stateChanged', SseEvent.OrchestratorStateChanged, ['state', 'prevState']],
|
||||
['planProgress', SseEvent.OrchestratorPlanProgress, ['phase', 'detail']],
|
||||
['planReady', SseEvent.OrchestratorPlanReady, ['plan']],
|
||||
['phaseStarted', SseEvent.OrchestratorPhaseStarted, ['phase']],
|
||||
['phaseCompleted', SseEvent.OrchestratorPhaseCompleted, ['phase']],
|
||||
['phaseFailed', SseEvent.OrchestratorPhaseFailed, ['phase', 'reason']],
|
||||
['taskAssigned', SseEvent.OrchestratorTaskAssigned, ['task', 'sessionId']],
|
||||
['taskCompleted', SseEvent.OrchestratorTaskCompleted, ['task']],
|
||||
['taskFailed', SseEvent.OrchestratorTaskFailed, ['task', 'error']],
|
||||
['completed', SseEvent.OrchestratorCompleted, ['stats']],
|
||||
];
|
||||
|
||||
let forwardingLoop: import('../../orchestrator-loop.js').OrchestratorLoop | null = null;
|
||||
function setupEventForwarding(loop: import('../../orchestrator-loop.js').OrchestratorLoop) {
|
||||
if (forwardingLoop === loop) return; // Already attached to this loop instance
|
||||
forwardingLoop = loop;
|
||||
loop.on('stateChanged', (state, prevState) => {
|
||||
ctx.broadcast(SseEvent.OrchestratorStateChanged, { state, prevState });
|
||||
});
|
||||
loop.on('planProgress', (phase, detail) => {
|
||||
ctx.broadcast(SseEvent.OrchestratorPlanProgress, { phase, detail });
|
||||
});
|
||||
loop.on('planReady', (plan) => {
|
||||
ctx.broadcast(SseEvent.OrchestratorPlanReady, { plan });
|
||||
});
|
||||
loop.on('phaseStarted', (phase) => {
|
||||
ctx.broadcast(SseEvent.OrchestratorPhaseStarted, { phase });
|
||||
});
|
||||
loop.on('phaseCompleted', (phase) => {
|
||||
ctx.broadcast(SseEvent.OrchestratorPhaseCompleted, { phase });
|
||||
});
|
||||
loop.on('phaseFailed', (phase, reason) => {
|
||||
ctx.broadcast(SseEvent.OrchestratorPhaseFailed, { phase, reason });
|
||||
});
|
||||
for (const [event, sseEvent, argNames] of EVENT_MAP) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
loop.on(event, (...args: any[]) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
argNames.forEach((name, i) => {
|
||||
payload[name] = args[i];
|
||||
});
|
||||
ctx.broadcast(sseEvent, payload);
|
||||
});
|
||||
}
|
||||
// Special cases with non-trivial payload transforms
|
||||
loop.on('verificationResult', (phase, result) => {
|
||||
ctx.broadcast(SseEvent.OrchestratorVerification, { phaseId: phase.id, result });
|
||||
});
|
||||
loop.on('taskAssigned', (task, sessionId) => {
|
||||
ctx.broadcast(SseEvent.OrchestratorTaskAssigned, { task, sessionId });
|
||||
});
|
||||
loop.on('taskCompleted', (task) => {
|
||||
ctx.broadcast(SseEvent.OrchestratorTaskCompleted, { task });
|
||||
});
|
||||
loop.on('taskFailed', (task, error) => {
|
||||
ctx.broadcast(SseEvent.OrchestratorTaskFailed, { task, error });
|
||||
});
|
||||
loop.on('completed', (stats) => {
|
||||
ctx.broadcast(SseEvent.OrchestratorCompleted, { stats });
|
||||
});
|
||||
loop.on('error', (error) => {
|
||||
ctx.broadcast(SseEvent.OrchestratorError, { error: error.message });
|
||||
});
|
||||
|
||||
@@ -308,7 +308,7 @@ export function registerRalphRoutes(
|
||||
useMux: true,
|
||||
mode: 'claude',
|
||||
niceConfig,
|
||||
model: rlModelConfig?.defaultModel,
|
||||
model: rlModelConfig?.defaultModel || undefined,
|
||||
claudeMode: rlClaudeModeConfig.claudeMode,
|
||||
allowedTools: rlClaudeModeConfig.allowedTools,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import {
|
||||
ApiErrorCode,
|
||||
@@ -42,7 +43,7 @@ import {
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js';
|
||||
import { writeHooksConfig, updateCaseEnvVars, updateCaseModel } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
@@ -166,6 +167,11 @@ export function registerSessionRoutes(
|
||||
await updateCaseEnvVars(workingDir, body.envOverrides);
|
||||
}
|
||||
|
||||
// Write model override to .claude/settings.local.json if provided
|
||||
if (body.modelOverride !== undefined) {
|
||||
await updateCaseModel(workingDir, body.modelOverride || null);
|
||||
}
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
if (body.mode === 'opencode') {
|
||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||||
@@ -177,11 +183,45 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-validate resumeSessionId: check that the conversation file actually exists
|
||||
// in Claude's projects directory. If not, skip resume to avoid confusing
|
||||
// "No conversation found" errors from Claude CLI.
|
||||
let validatedResumeId = body.resumeSessionId;
|
||||
if (validatedResumeId) {
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
let found = false;
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const sessionFile = join(projectsDir, projDir, `${validatedResumeId}.jsonl`);
|
||||
try {
|
||||
const stat = await fs.stat(sessionFile);
|
||||
if (stat.size > 4000) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist in this project dir
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Projects dir doesn't exist
|
||||
}
|
||||
if (!found) {
|
||||
console.log(`[Session] Resume session ${validatedResumeId} not found on disk, starting fresh`);
|
||||
validatedResumeId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const globalNice = await ctx.getGlobalNiceConfig();
|
||||
const modelConfig = await ctx.getModelConfig();
|
||||
const mode = body.mode || 'claude';
|
||||
const model =
|
||||
mode === 'opencode' ? body.openCodeConfig?.model : mode !== 'shell' ? modelConfig?.defaultModel : undefined;
|
||||
mode === 'opencode'
|
||||
? body.openCodeConfig?.model
|
||||
: mode !== 'shell'
|
||||
? modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
@@ -194,7 +234,7 @@ export function registerSessionRoutes(
|
||||
claudeMode: claudeModeConfig.claudeMode,
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||
resumeSessionId: body.resumeSessionId,
|
||||
resumeSessionId: validatedResumeId,
|
||||
});
|
||||
|
||||
ctx.addSession(session);
|
||||
@@ -485,6 +525,46 @@ export function registerSessionRoutes(
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ========== Send Named Key (tmux send-keys -H) ==========
|
||||
// Sends raw hex bytes to tmux pane for keys like Shift+Enter / Ctrl+Enter.
|
||||
// Uses send-keys -H (hex) to inject 0x0a (line feed) which Claude Code's
|
||||
// Ink input recognizes as "insert newline" vs 0x0d (carriage return = submit).
|
||||
|
||||
app.post('/api/sessions/:id/send-key', async (req): Promise<ApiResponse> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const key = typeof body?.key === 'string' ? body.key : '';
|
||||
|
||||
// Map key names to hex byte sequences
|
||||
const KEY_HEX_MAP: Record<string, string[]> = {
|
||||
'S-Enter': ['0a'], // \n (line feed)
|
||||
'C-Enter': ['0a'], // \n (line feed)
|
||||
};
|
||||
const hex = KEY_HEX_MAP[key];
|
||||
if (!hex) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
const muxName = session.muxName;
|
||||
if (!muxName) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
execFile('tmux', ['send-keys', '-H', '-t', muxName, ...hex], { timeout: 5000 }, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Server] send-key failed:', err);
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ========== Resize Terminal ==========
|
||||
|
||||
app.post('/api/sessions/:id/resize', async (req): Promise<ApiResponse> => {
|
||||
@@ -496,6 +576,117 @@ export function registerSessionRoutes(
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ========== Get Last Response (from transcript JSONL) ==========
|
||||
|
||||
app.get('/api/sessions/:id/last-response', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
// The Claude conversation ID (used as JSONL filename)
|
||||
const claudeSessionId = session.claudeSessionId || session.id;
|
||||
|
||||
// Scan ~/.claude/projects/*/ for the transcript file
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
let transcriptText = '';
|
||||
let transcriptTimestamp = '';
|
||||
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const jsonlPath = join(projectsDir, projDir, `${claudeSessionId}.jsonl`);
|
||||
try {
|
||||
const content = await fs.readFile(jsonlPath, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
// Search from end for last assistant message with text
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const entry = JSON.parse(lines[i]);
|
||||
if (entry.type === 'assistant' && entry.message?.content) {
|
||||
const blocks = Array.isArray(entry.message.content)
|
||||
? entry.message.content
|
||||
: [{ type: 'text', text: String(entry.message.content) }];
|
||||
const textBlocks = blocks
|
||||
.filter((b: { type: string; text?: string }) => b.type === 'text' && b.text)
|
||||
.map((b: { type: string; text?: string }) => b.text);
|
||||
if (textBlocks.length > 0) {
|
||||
transcriptText = textBlocks.join('\n\n');
|
||||
transcriptTimestamp = entry.timestamp || '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
if (transcriptText) break; // Found it, stop scanning directories
|
||||
} catch {
|
||||
// File doesn't exist in this project dir, continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// projects dir doesn't exist
|
||||
}
|
||||
|
||||
// If ?context=full, return all user+assistant messages for conversation view
|
||||
const query = req.query as { context?: string };
|
||||
if (query.context === 'full' && transcriptText) {
|
||||
const allMessages: Array<{ role: string; text: string; timestamp?: string }> = [];
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const jsonlPath = join(projectsDir, projDir, `${claudeSessionId}.jsonl`);
|
||||
try {
|
||||
const content = await fs.readFile(jsonlPath, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === 'user' && entry.message?.content) {
|
||||
const text =
|
||||
typeof entry.message.content === 'string'
|
||||
? entry.message.content
|
||||
: (entry.message.content as Array<{ type: string; text?: string }>)
|
||||
.filter((b) => b.type === 'text' && b.text)
|
||||
.map((b) => b.text)
|
||||
.join('\n');
|
||||
// Skip system/command messages
|
||||
if (text && !text.startsWith('<local-command') && !text.startsWith('<command-name>')) {
|
||||
allMessages.push({ role: 'user', text, timestamp: entry.timestamp });
|
||||
}
|
||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||
const blocks = Array.isArray(entry.message.content)
|
||||
? entry.message.content
|
||||
: [{ type: 'text', text: String(entry.message.content) }];
|
||||
const text = blocks
|
||||
.filter((b: { type: string; text?: string }) => b.type === 'text' && b.text)
|
||||
.map((b: { type: string; text?: string }) => b.text)
|
||||
.join('\n\n');
|
||||
if (text) {
|
||||
allMessages.push({ role: 'assistant', text, timestamp: entry.timestamp });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
if (allMessages.length > 0) break;
|
||||
} catch {
|
||||
/* continue */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { text: transcriptText, timestamp: transcriptTimestamp, messages: allMessages };
|
||||
}
|
||||
|
||||
return {
|
||||
text: transcriptText,
|
||||
timestamp: transcriptTimestamp,
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Get Terminal Buffer ==========
|
||||
|
||||
// Query params:
|
||||
@@ -761,7 +952,11 @@ export function registerSessionRoutes(
|
||||
const niceConfig = await ctx.getGlobalNiceConfig();
|
||||
const qsModelConfig = await ctx.getModelConfig();
|
||||
const qsModel =
|
||||
mode === 'opencode' ? openCodeConfig?.model : mode !== 'shell' ? qsModelConfig?.defaultModel : undefined;
|
||||
mode === 'opencode'
|
||||
? openCodeConfig?.model
|
||||
: mode !== 'shell'
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const session = new Session({
|
||||
workingDir: casePath,
|
||||
@@ -911,6 +1106,74 @@ export function registerSessionRoutes(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a Claude project key (e.g. "-Users-teigen-Documents-Workspace-AI-project-Mirror")
|
||||
* back to a filesystem path ("/Users/teigen/Documents/Workspace/AI_project/Mirror").
|
||||
*
|
||||
* Claude CLI encodes both '/' and '_' as '-', so each '-' in the key could be
|
||||
* any of: '/' (path separator), '_' (underscore), or '-' (literal dash).
|
||||
*
|
||||
* Strategy: look-ahead matching. At each '-', try consuming multiple segments
|
||||
* joined by '_' or '-' to find an existing child directory, then recurse.
|
||||
* E.g. for segments [AI, project, Mirror] inside /Workspace:
|
||||
* try /Workspace/AI (no) -> /Workspace/AI_project (yes!) -> continue with [Mirror]
|
||||
*/
|
||||
async function decodeProjectKey(projKey: string): Promise<string> {
|
||||
const encoded = projKey.startsWith('-') ? projKey.slice(1) : projKey;
|
||||
const segments = encoded.split('-');
|
||||
|
||||
const isDir = async (p: string): Promise<boolean> =>
|
||||
fs
|
||||
.stat(p)
|
||||
.then((s) => s.isDirectory())
|
||||
.catch(() => false);
|
||||
|
||||
let current = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < segments.length) {
|
||||
// Try progressively longer child names by joining segments with '_' or '-'
|
||||
let matched = false;
|
||||
// Limit look-ahead to avoid excessive fs checks (max 4 segments per component)
|
||||
const maxLook = Math.min(i + 4, segments.length);
|
||||
for (let end = i; end < maxLook; end++) {
|
||||
// Build candidate child name from segments[i..end]
|
||||
// Try all separator combinations: for 2+ segments, try '_' first then '-'
|
||||
const candidates: string[] = [];
|
||||
if (end === i) {
|
||||
candidates.push(segments[i]);
|
||||
} else {
|
||||
// Build with underscores between joined segments
|
||||
candidates.push(segments.slice(i, end + 1).join('_'));
|
||||
// Build with dashes (literal)
|
||||
candidates.push(segments.slice(i, end + 1).join('-'));
|
||||
}
|
||||
|
||||
for (const child of candidates) {
|
||||
const candidate = current + '/' + child;
|
||||
if (await isDir(candidate)) {
|
||||
current = candidate;
|
||||
i = end + 1;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) break;
|
||||
}
|
||||
if (!matched) {
|
||||
// No directory match found — append as-is and move on
|
||||
current = current + '/' + segments[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
const finalExists = await fs
|
||||
.access(current)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
return finalExists ? current : process.env.HOME || '/tmp';
|
||||
}
|
||||
|
||||
/** Read the first 16KB of a file for content sniffing. */
|
||||
async function readFileHead(path: string, buf: Buffer): Promise<string | null> {
|
||||
try {
|
||||
@@ -961,15 +1224,11 @@ export function registerSessionRoutes(
|
||||
const stat = await fs.stat(projPath).catch(() => null);
|
||||
if (!stat?.isDirectory()) continue;
|
||||
|
||||
// Decode project key to working dir. The encoding replaces '/' with '-',
|
||||
// which is lossy when path components contain '-'. Do naive decode first,
|
||||
// then verify it exists. Fall back to HOME if the decoded path is invalid.
|
||||
const naiveDecode = projDir.replace(/^-/, '/').replace(/-/g, '/');
|
||||
const dirExists = await fs
|
||||
.access(naiveDecode)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const workingDir = dirExists ? naiveDecode : process.env.HOME || '/tmp';
|
||||
// Decode project key to working dir. Claude CLI encodes '/' as '-',
|
||||
// but path components may also contain '-' (e.g. "AI_project" vs "AI-project").
|
||||
// Use recursive backtracking: try each '-' as either '/' or literal '-',
|
||||
// verify which decoded path actually exists on disk.
|
||||
const workingDir = await decodeProjectKey(projDir);
|
||||
|
||||
const entries = await fs.readdir(projPath);
|
||||
for (const entry of entries) {
|
||||
@@ -991,24 +1250,31 @@ export function registerSessionRoutes(
|
||||
// Read first 16KB to check content and extract first user prompt.
|
||||
let firstPrompt: string | undefined;
|
||||
const head = await readFileHead(filePath, headBuf);
|
||||
const hasConversation = (text: string) =>
|
||||
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
|
||||
|
||||
if (fileStat.size < 50000) {
|
||||
if (
|
||||
!head ||
|
||||
(!head.includes('"type":"user"') &&
|
||||
!head.includes('"type":"assistant"') &&
|
||||
!head.includes('"type":"summary"'))
|
||||
) {
|
||||
continue; // No conversation content — skip
|
||||
}
|
||||
let foundContent = head ? hasConversation(head) : false;
|
||||
|
||||
// For large files, head may not contain user messages (e.g. /init followed
|
||||
// by large system entries). Check the tail as well.
|
||||
let tail: string | null = null;
|
||||
if (!foundContent && fileStat.size > 16384) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
if (tail) foundContent = hasConversation(tail);
|
||||
}
|
||||
|
||||
if (!foundContent) continue; // No conversation content — skip
|
||||
|
||||
if (head) firstPrompt = extractFirstUserPrompt(head);
|
||||
|
||||
// If head scan found no usable prompt (e.g. session started with /init),
|
||||
// try reading the tail for a recent user message.
|
||||
if (!firstPrompt && fileStat.size > 65536) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
const tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
if (!tail) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
}
|
||||
if (tail) firstPrompt = extractFirstUserPrompt(tail);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,14 @@ import {
|
||||
import { subagentWatcher } from '../../subagent-watcher.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import { findSessionOrFail, formatUptime, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import {
|
||||
findSessionOrFail,
|
||||
formatUptime,
|
||||
parseBody,
|
||||
readJsonConfig,
|
||||
toggleService,
|
||||
SETTINGS_PATH,
|
||||
} from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
@@ -281,15 +288,20 @@ export function registerSystemRoutes(
|
||||
|
||||
// ========== Stats ==========
|
||||
|
||||
app.get('/api/stats', async () => {
|
||||
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
|
||||
function collectActiveTokens(): Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> {
|
||||
const tokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
|
||||
for (const [sessionId, session] of ctx.sessions) {
|
||||
activeSessionTokens[sessionId] = {
|
||||
tokens[sessionId] = {
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
};
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
app.get('/api/stats', async () => {
|
||||
const activeSessionTokens = collectActiveTokens();
|
||||
return {
|
||||
success: true,
|
||||
stats: ctx.store.getAggregateStats(activeSessionTokens),
|
||||
@@ -298,14 +310,7 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
app.get('/api/token-stats', async () => {
|
||||
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
|
||||
for (const [sessionId, session] of ctx.sessions) {
|
||||
activeSessionTokens[sessionId] = {
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
};
|
||||
}
|
||||
const activeSessionTokens = collectActiveTokens();
|
||||
return {
|
||||
success: true,
|
||||
daily: ctx.store.getDailyStats(30),
|
||||
@@ -414,30 +419,17 @@ export function registerSystemRoutes(
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||
|
||||
// Handle subagent tracking toggle dynamically
|
||||
const subagentEnabled = settings.subagentTrackingEnabled ?? true;
|
||||
if (subagentEnabled && !subagentWatcher.isRunning()) {
|
||||
subagentWatcher.start();
|
||||
console.log('Subagent watcher started via settings change');
|
||||
} else if (!subagentEnabled && subagentWatcher.isRunning()) {
|
||||
subagentWatcher.stop();
|
||||
console.log('Subagent watcher stopped via settings change');
|
||||
}
|
||||
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
||||
|
||||
// Handle image watcher toggle dynamically
|
||||
const imageWatcherEnabled = settings.imageWatcherEnabled ?? false;
|
||||
if (imageWatcherEnabled && !imageWatcher.isRunning()) {
|
||||
imageWatcher.start();
|
||||
toggleService((settings.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
|
||||
// Re-watch all active sessions that have image watcher enabled
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.imageWatcherEnabled) {
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
}
|
||||
}
|
||||
console.log('Image watcher started via settings change');
|
||||
} else if (!imageWatcherEnabled && imageWatcher.isRunning()) {
|
||||
imageWatcher.stop();
|
||||
console.log('Image watcher stopped via settings change');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle tunnel toggle dynamically
|
||||
if ('tunnelEnabled' in settings) {
|
||||
|
||||
@@ -124,6 +124,8 @@ export const CreateSessionSchema = z.object({
|
||||
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
||||
name: z.string().max(100).optional(),
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
|
||||
modelOverride: z.string().max(50).optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||
resumeSessionId: z
|
||||
@@ -429,6 +431,11 @@ export const LinkCaseSchema = z.object({
|
||||
path: safePathSchema,
|
||||
});
|
||||
|
||||
/** PUT /api/cases/order */
|
||||
export const CaseOrderSchema = z.object({
|
||||
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
|
||||
});
|
||||
|
||||
/** POST /api/auth/revoke */
|
||||
export const RevokeSessionSchema = z.object({
|
||||
sessionToken: z.string().min(1).max(200).optional(),
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* @fileoverview Session event listener wiring — creates, attaches, and detaches session listeners.
|
||||
*
|
||||
* Extracted from server.ts for modularity. Provides:
|
||||
* - `SessionListenerRefs` interface (named listener references for leak-free cleanup)
|
||||
* - `createSessionListeners()` — builds all 25 listener handlers via dependency injection
|
||||
* - `attachSessionListeners()` / `detachSessionListeners()` — symmetric attach/detach
|
||||
*
|
||||
* The detach function deduplicates a pattern that was previously copy-pasted 3 times
|
||||
* in server.ts (_doCleanupSession, exit handler, stop()).
|
||||
*
|
||||
* @dependencies session.ts (Session, event types), sse-events.ts, types.ts
|
||||
* @consumedby web/server.ts (WebServer delegates listener lifecycle here)
|
||||
*
|
||||
* @module web/session-listener-wiring
|
||||
*/
|
||||
|
||||
import type {
|
||||
Session,
|
||||
ClaudeMessage,
|
||||
BackgroundTask,
|
||||
RalphTrackerState,
|
||||
RalphTodoItem,
|
||||
ActiveBashTool,
|
||||
} from '../session.js';
|
||||
import type { RalphStatusBlock, CircuitBreakerStatus } from '../types.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||
import { fileStreamManager } from '../file-stream-manager.js';
|
||||
|
||||
/** Stored listener references for session cleanup (prevents memory leaks) */
|
||||
export interface SessionListenerRefs {
|
||||
terminal: (data: string) => void;
|
||||
clearTerminal: () => void;
|
||||
needsRefresh: () => void;
|
||||
message: (msg: ClaudeMessage) => void;
|
||||
error: (error: string) => void;
|
||||
completion: (result: string, cost: number) => void;
|
||||
exit: (code: number | null) => void;
|
||||
working: () => void;
|
||||
idle: () => void;
|
||||
taskCreated: (task: BackgroundTask) => void;
|
||||
taskUpdated: (task: BackgroundTask) => void;
|
||||
taskCompleted: (task: BackgroundTask) => void;
|
||||
taskFailed: (task: BackgroundTask, error: string) => void;
|
||||
autoClear: (data: { tokens: number; threshold: number }) => void;
|
||||
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
|
||||
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
|
||||
ralphLoopUpdate: (state: RalphTrackerState) => void;
|
||||
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
|
||||
ralphCompletionDetected: (phrase: string) => void;
|
||||
ralphStatusBlockDetected: (block: RalphStatusBlock) => void;
|
||||
ralphCircuitBreakerUpdate: (status: CircuitBreakerStatus) => void;
|
||||
ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void;
|
||||
bashToolStart: (tool: ActiveBashTool) => void;
|
||||
bashToolEnd: (tool: ActiveBashTool) => void;
|
||||
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
||||
}
|
||||
|
||||
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
|
||||
export interface SessionListenerDeps {
|
||||
broadcast(event: string, data: unknown): void;
|
||||
batchTerminalData(sessionId: string, data: string): void;
|
||||
batchTaskUpdate(sessionId: string, task: BackgroundTask): void;
|
||||
broadcastSessionStateDebounced(sessionId: string): void;
|
||||
sendPushNotifications(event: string, data: Record<string, unknown>): void;
|
||||
persistSessionState(session: Session): void;
|
||||
getSessionStateWithRespawn(session: Session): unknown;
|
||||
getRunSummaryTracker(sessionId: string): import('../run-summary.js').RunSummaryTracker | undefined;
|
||||
stopTranscriptWatcher(sessionId: string): void;
|
||||
cleanupSessionBatches(sessionId: string): void;
|
||||
cancelPersistDebounce(sessionId: string): void;
|
||||
removeRunSummaryTracker(sessionId: string): void;
|
||||
removeSessionListenerRefs(sessionId: string): void;
|
||||
cleanupRespawnOnExit(sessionId: string): void;
|
||||
getStore(): import('../state-store.js').StateStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates all 25 session listener handlers, capturing dependencies via closure.
|
||||
* Call `attachSessionListeners()` after to wire them to the session.
|
||||
*/
|
||||
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
|
||||
return {
|
||||
// ─── Terminal Output ─────────────────────────────────────
|
||||
|
||||
/** Batches PTY output → broadcasts `session:terminal` at 16-50ms intervals */
|
||||
terminal: (data) => {
|
||||
deps.batchTerminalData(session.id, data);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:clearTerminal` — tells clients to wipe their xterm buffer (after mux attach) */
|
||||
clearTerminal: () => {
|
||||
deps.broadcast(SseEvent.SessionClearTerminal, { id: session.id });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:needsRefresh` — tells clients to reload buffer */
|
||||
needsRefresh: () => {
|
||||
deps.broadcast(SseEvent.SessionNeedsRefresh, { id: session.id });
|
||||
},
|
||||
|
||||
// ─── Session Messages & Errors ──────────────────────────
|
||||
|
||||
/** Broadcasts `session:message` — structured Claude JSON messages (assistant, tool_use, etc.) */
|
||||
message: (msg: ClaudeMessage) => {
|
||||
deps.broadcast(SseEvent.SessionMessage, { id: session.id, message: msg });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:error` + sends push notification */
|
||||
error: (error) => {
|
||||
deps.broadcast(SseEvent.SessionError, { id: session.id, error });
|
||||
deps.sendPushNotifications(SseEvent.SessionError, {
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
error: String(error),
|
||||
});
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) tracker.recordError('Session error', String(error));
|
||||
},
|
||||
|
||||
/** Broadcasts `session:completion` + `session:updated` — prompt finished, persists state */
|
||||
completion: (result, cost) => {
|
||||
deps.broadcast(SseEvent.SessionCompletion, { id: session.id, result, cost });
|
||||
deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
|
||||
deps.persistSessionState(session);
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) tracker.recordTokens(session.inputTokens, session.outputTokens);
|
||||
},
|
||||
|
||||
// ─── Session Lifecycle ──────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:exit` + `session:updated` — PTY process exited; cleans up respawn, timers, listeners */
|
||||
exit: (code) => {
|
||||
getLifecycleLog().log({
|
||||
event: 'exit',
|
||||
sessionId: session.id,
|
||||
name: session.name,
|
||||
exitCode: code,
|
||||
});
|
||||
// Wrap in try/catch to ensure cleanup always happens
|
||||
try {
|
||||
deps.broadcast(SseEvent.SessionExit, { id: session.id, code });
|
||||
deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
|
||||
deps.persistSessionState(session);
|
||||
} catch (err) {
|
||||
console.error(`[Server] Error broadcasting session exit for ${session.id}:`, err);
|
||||
}
|
||||
|
||||
// Always clean up respawn controller, even if broadcast failed
|
||||
try {
|
||||
deps.cleanupRespawnOnExit(session.id);
|
||||
} catch (err) {
|
||||
console.error(`[Server] Error cleaning up respawn controller for ${session.id}:`, err);
|
||||
}
|
||||
|
||||
// Clean up per-session resources that are stale after PTY exit.
|
||||
try {
|
||||
// Transcript watcher is tied to the specific PTY run
|
||||
deps.stopTranscriptWatcher(session.id);
|
||||
|
||||
// Finalize run summary tracker
|
||||
deps.removeRunSummaryTracker(session.id);
|
||||
|
||||
// Flush/clear terminal batching state (no more output coming)
|
||||
deps.cleanupSessionBatches(session.id);
|
||||
|
||||
// Clear pending persist-debounce timer
|
||||
deps.cancelPersistDebounce(session.id);
|
||||
|
||||
// Close any active file streams
|
||||
fileStreamManager.closeSessionStreams(session.id);
|
||||
|
||||
// Remove stored listener refs to break closure references (prevents memory leak).
|
||||
deps.removeSessionListenerRefs(session.id);
|
||||
} catch (err) {
|
||||
console.error(`[Server] Error cleaning up session resources on exit for ${session.id}:`, err);
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Activity State ─────────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:working` — Claude started processing */
|
||||
working: () => {
|
||||
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) {
|
||||
tracker.recordWorking();
|
||||
tracker.recordTokens(session.inputTokens, session.outputTokens);
|
||||
}
|
||||
},
|
||||
|
||||
/** Broadcasts `session:idle` — Claude finished processing, waiting for input */
|
||||
idle: () => {
|
||||
deps.broadcast(SseEvent.SessionIdle, { id: session.id });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) {
|
||||
tracker.recordIdle();
|
||||
tracker.recordTokens(session.inputTokens, session.outputTokens);
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Background Task Events ──────────────────────────────
|
||||
|
||||
/** Broadcasts `task:created` — new background task discovered */
|
||||
taskCreated: (task: BackgroundTask) => {
|
||||
deps.broadcast(SseEvent.TaskCreated, { sessionId: session.id, task });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
/** Batched broadcast of `task:updated` — high-frequency progress updates */
|
||||
taskUpdated: (task: BackgroundTask) => {
|
||||
deps.batchTaskUpdate(session.id, task);
|
||||
},
|
||||
|
||||
/** Broadcasts `task:completed` — background task finished successfully */
|
||||
taskCompleted: (task: BackgroundTask) => {
|
||||
deps.broadcast(SseEvent.TaskCompleted, { sessionId: session.id, task });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
/** Broadcasts `task:failed` — background task errored */
|
||||
taskFailed: (task: BackgroundTask, error: string) => {
|
||||
deps.broadcast(SseEvent.TaskFailed, { sessionId: session.id, task, error });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
// ─── Auto-Operations ────────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:autoClear` — context window auto-cleared at token threshold */
|
||||
autoClear: (data: { tokens: number; threshold: number }) => {
|
||||
deps.broadcast(SseEvent.SessionAutoClear, { sessionId: session.id, ...data });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) tracker.recordAutoClear(data.tokens, data.threshold);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:autoCompact` — context window auto-compacted at token threshold */
|
||||
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => {
|
||||
deps.broadcast(SseEvent.SessionAutoCompact, { sessionId: session.id, ...data });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) tracker.recordAutoCompact(data.tokens, data.threshold);
|
||||
},
|
||||
|
||||
// ─── CLI Info ────────────────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
|
||||
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => {
|
||||
deps.broadcast(SseEvent.SessionCliInfo, { sessionId: session.id, ...data });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
// ─── Ralph Tracking Events ──────────────────────────────
|
||||
|
||||
/** Broadcasts `session:ralphLoopUpdate` — Ralph tracker loop state changed (iteration, phase) */
|
||||
ralphLoopUpdate: (state: RalphTrackerState) => {
|
||||
deps.broadcast(SseEvent.SessionRalphLoopUpdate, { sessionId: session.id, state });
|
||||
deps.getStore().updateRalphState(session.id, { loop: state });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:ralphTodoUpdate` — todo items added, completed, or modified */
|
||||
ralphTodoUpdate: (todos: RalphTodoItem[]) => {
|
||||
deps.broadcast(SseEvent.SessionRalphTodoUpdate, { sessionId: session.id, todos });
|
||||
deps.getStore().updateRalphState(session.id, { todos });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:ralphCompletionDetected` + push notification — completion phrase matched */
|
||||
ralphCompletionDetected: (phrase: string) => {
|
||||
deps.broadcast(SseEvent.SessionRalphCompletionDetected, { sessionId: session.id, phrase });
|
||||
deps.sendPushNotifications(SseEvent.SessionRalphCompletionDetected, {
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
phrase,
|
||||
});
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) tracker.recordRalphCompletion(phrase);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:ralphStatusUpdate` — RALPH_STATUS block parsed from output */
|
||||
ralphStatusBlockDetected: (block: RalphStatusBlock) => {
|
||||
deps.broadcast(SseEvent.SessionRalphStatusUpdate, { sessionId: session.id, block });
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) {
|
||||
tracker.addEvent(
|
||||
block.status === 'BLOCKED' ? 'warning' : 'idle_detected',
|
||||
block.status === 'BLOCKED' ? 'warning' : 'info',
|
||||
`Ralph Status: ${block.status}`,
|
||||
`Tasks: ${block.tasksCompletedThisLoop}, Files: ${block.filesModified}, Tests: ${block.testsStatus}`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/** Broadcasts `session:circuitBreakerUpdate` — circuit breaker state changed (CLOSED/HALF_OPEN/OPEN) */
|
||||
ralphCircuitBreakerUpdate: (status: CircuitBreakerStatus) => {
|
||||
deps.broadcast(SseEvent.SessionCircuitBreakerUpdate, { sessionId: session.id, status });
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker && status.state === 'OPEN') {
|
||||
tracker.addEvent('warning', 'warning', 'Circuit Breaker Opened', status.reason);
|
||||
}
|
||||
},
|
||||
|
||||
/** Broadcasts `session:exitGateMet` — all completion indicators met, ready to exit */
|
||||
ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => {
|
||||
deps.broadcast(SseEvent.SessionExitGateMet, { sessionId: session.id, ...data });
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) {
|
||||
tracker.addEvent(
|
||||
'ralph_completion',
|
||||
'success',
|
||||
'Exit Gate Met',
|
||||
`Indicators: ${data.completionIndicators}, EXIT_SIGNAL: ${data.exitSignal}`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Bash Tool Tracking ────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:bashToolStart` — bash tool invocation started */
|
||||
bashToolStart: (tool: ActiveBashTool) => {
|
||||
deps.broadcast(SseEvent.SessionBashToolStart, { sessionId: session.id, tool });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:bashToolEnd` — bash tool invocation completed */
|
||||
bashToolEnd: (tool: ActiveBashTool) => {
|
||||
deps.broadcast(SseEvent.SessionBashToolEnd, { sessionId: session.id, tool });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:bashToolsUpdate` — full active bash tools list refreshed */
|
||||
bashToolsUpdate: (tools: ActiveBashTool[]) => {
|
||||
deps.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Attach all listeners to a session. */
|
||||
export function attachSessionListeners(session: Session, refs: SessionListenerRefs): void {
|
||||
session.on('terminal', refs.terminal);
|
||||
session.on('clearTerminal', refs.clearTerminal);
|
||||
session.on('needsRefresh', refs.needsRefresh);
|
||||
session.on('message', refs.message);
|
||||
session.on('error', refs.error);
|
||||
session.on('completion', refs.completion);
|
||||
session.on('exit', refs.exit);
|
||||
session.on('working', refs.working);
|
||||
session.on('idle', refs.idle);
|
||||
session.on('taskCreated', refs.taskCreated);
|
||||
session.on('taskUpdated', refs.taskUpdated);
|
||||
session.on('taskCompleted', refs.taskCompleted);
|
||||
session.on('taskFailed', refs.taskFailed);
|
||||
session.on('autoClear', refs.autoClear);
|
||||
session.on('autoCompact', refs.autoCompact);
|
||||
session.on('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.on('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.on('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
session.on('ralphCompletionDetected', refs.ralphCompletionDetected);
|
||||
session.on('ralphStatusBlockDetected', refs.ralphStatusBlockDetected);
|
||||
session.on('ralphCircuitBreakerUpdate', refs.ralphCircuitBreakerUpdate);
|
||||
session.on('ralphExitGateMet', refs.ralphExitGateMet);
|
||||
session.on('bashToolStart', refs.bashToolStart);
|
||||
session.on('bashToolEnd', refs.bashToolEnd);
|
||||
session.on('bashToolsUpdate', refs.bashToolsUpdate);
|
||||
}
|
||||
|
||||
/** Detach all listeners from a session (prevents memory leaks from closure references). */
|
||||
export function detachSessionListeners(session: Session, refs: SessionListenerRefs): void {
|
||||
session.off('terminal', refs.terminal);
|
||||
session.off('clearTerminal', refs.clearTerminal);
|
||||
session.off('needsRefresh', refs.needsRefresh);
|
||||
session.off('message', refs.message);
|
||||
session.off('error', refs.error);
|
||||
session.off('completion', refs.completion);
|
||||
session.off('exit', refs.exit);
|
||||
session.off('working', refs.working);
|
||||
session.off('idle', refs.idle);
|
||||
session.off('taskCreated', refs.taskCreated);
|
||||
session.off('taskUpdated', refs.taskUpdated);
|
||||
session.off('taskCompleted', refs.taskCompleted);
|
||||
session.off('taskFailed', refs.taskFailed);
|
||||
session.off('autoClear', refs.autoClear);
|
||||
session.off('autoCompact', refs.autoCompact);
|
||||
session.off('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.off('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.off('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
session.off('ralphCompletionDetected', refs.ralphCompletionDetected);
|
||||
session.off('ralphStatusBlockDetected', refs.ralphStatusBlockDetected);
|
||||
session.off('ralphCircuitBreakerUpdate', refs.ralphCircuitBreakerUpdate);
|
||||
session.off('ralphExitGateMet', refs.ralphExitGateMet);
|
||||
session.off('bashToolStart', refs.bashToolStart);
|
||||
session.off('bashToolEnd', refs.bashToolEnd);
|
||||
session.off('bashToolsUpdate', refs.bashToolsUpdate);
|
||||
}
|
||||
@@ -325,6 +325,10 @@ export const OrchestratorError = 'orchestrator:error' as const;
|
||||
export const CaseCreated = 'case:created' as const;
|
||||
/** Existing directory linked as a case. */
|
||||
export const CaseLinked = 'case:linked' as const;
|
||||
/** Case deleted or unlinked. */
|
||||
export const CaseDeleted = 'case:deleted' as const;
|
||||
/** Case ordering changed. */
|
||||
export const CaseOrderChanged = 'case:order-changed' as const;
|
||||
|
||||
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -485,4 +489,6 @@ export const SseEvent = {
|
||||
// Cases
|
||||
CaseCreated,
|
||||
CaseLinked,
|
||||
CaseDeleted,
|
||||
CaseOrderChanged,
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* @fileoverview SSE stream manager — owns all SSE client state, broadcasting, and event batching.
|
||||
*
|
||||
* Extracted from server.ts for modularity. Handles:
|
||||
* - SSE client connection tracking with subscription filtering
|
||||
* - Backpressure-aware message delivery
|
||||
* - Terminal data batching with adaptive intervals (16-50ms for 60fps)
|
||||
* - Task update and session state batching
|
||||
* - Dead client cleanup and keepalive
|
||||
* - Cloudflare tunnel padding for proxy buffer flushing
|
||||
*
|
||||
* @dependencies CleanupManager (managed timers), config/server-timing (constants)
|
||||
* @consumedby web/server.ts (WebServer delegates all SSE operations here)
|
||||
*
|
||||
* @module web/sse-stream-manager
|
||||
*/
|
||||
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import type { BackgroundTask } from '../session.js';
|
||||
import { CleanupManager, StaleExpirationMap } from '../utils/index.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import {
|
||||
TERMINAL_BATCH_INTERVAL,
|
||||
TASK_UPDATE_BATCH_INTERVAL,
|
||||
STATE_UPDATE_DEBOUNCE_INTERVAL,
|
||||
BATCH_FLUSH_THRESHOLD,
|
||||
SSE_PADDING_SIZE,
|
||||
INACTIVITY_TIMEOUT_MS,
|
||||
} from '../config/server-timing.js';
|
||||
|
||||
// SSE padding for Cloudflare tunnel buffer flushing.
|
||||
// Cloudflare quick tunnels buffer small SSE responses, causing lag for real-time events.
|
||||
// Appending SSE comment padding (ignored by EventSource) forces the proxy to flush.
|
||||
// Pre-computed once at startup to avoid repeated string allocation.
|
||||
const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n';
|
||||
|
||||
/** Dependencies injected by WebServer — keeps SseStreamManager decoupled from session/respawn state. */
|
||||
export interface SseStreamManagerDeps {
|
||||
/** Get session state with respawn info for session:updated broadcasts */
|
||||
getSessionStateWithRespawn(sessionId: string): unknown;
|
||||
}
|
||||
|
||||
export class SseStreamManager {
|
||||
// ─── SSE Client Tracking ────────────────────────────────
|
||||
/**
|
||||
* SSE clients mapped to their session subscription filter.
|
||||
* Value is a Set of session IDs the client wants events for,
|
||||
* or `null` meaning "receive all events" (backwards-compatible default).
|
||||
*/
|
||||
private sseClients: Map<FastifyReply, Set<string> | null> = new Map();
|
||||
/** SSE clients connecting from non-localhost (i.e. through tunnel) */
|
||||
private remoteSseClients: Set<FastifyReply> = new Set();
|
||||
/** Clients with backpressure — skip writes until 'drain' fires */
|
||||
private backpressuredClients: Set<FastifyReply> = new Set();
|
||||
|
||||
// ─── Tunnel State ───────────────────────────────────────
|
||||
/** Cached tunnel active state — updated on TunnelStarted/TunnelStopped to avoid getUrl() on every broadcast */
|
||||
private _isTunnelActive: boolean = false;
|
||||
|
||||
// ─── Terminal Batching ──────────────────────────────────
|
||||
private terminalBatches: Map<string, string[]> = new Map();
|
||||
private terminalBatchSizes: Map<string, number> = new Map(); // Running total avoids O(n) reduce per push
|
||||
private terminalBatchTimers: Map<string, NodeJS.Timeout> = new Map(); // Per-session timers (staggered flushes)
|
||||
// Adaptive batching: track rapid events to extend batch window (per-session)
|
||||
// StaleExpirationMap auto-cleans entries for sessions that stop generating output
|
||||
private lastTerminalEventTime: StaleExpirationMap<string, number>;
|
||||
|
||||
// ─── Event Batching ─────────────────────────────────────
|
||||
private taskUpdateBatches: Map<string, { sessionId: string; task: BackgroundTask }> = new Map();
|
||||
private taskUpdateBatchTimerId: string | null = null;
|
||||
// State update batching (reduce expensive toDetailedState() serialization)
|
||||
private stateUpdatePending: Set<string> = new Set();
|
||||
private stateUpdateTimerId: string | null = null;
|
||||
|
||||
// ─── Lifecycle ──────────────────────────────────────────
|
||||
private _isStopping: boolean = false;
|
||||
|
||||
constructor(
|
||||
private deps: SseStreamManagerDeps,
|
||||
private cleanup: CleanupManager
|
||||
) {
|
||||
this.lastTerminalEventTime = new StaleExpirationMap({
|
||||
ttlMs: INACTIVITY_TIMEOUT_MS, // 5 minutes - auto-expire stale session timing data
|
||||
refreshOnGet: false, // Don't refresh on reads, only on explicit sets
|
||||
});
|
||||
}
|
||||
|
||||
// ========== SSE Connection Management ==========
|
||||
|
||||
get clientCount(): number {
|
||||
return this.sseClients.size;
|
||||
}
|
||||
|
||||
get remoteClientCount(): number {
|
||||
return this.remoteSseClients.size;
|
||||
}
|
||||
|
||||
get isTunnelActive(): boolean {
|
||||
return this._isTunnelActive;
|
||||
}
|
||||
|
||||
setTunnelActive(active: boolean): void {
|
||||
this._isTunnelActive = active;
|
||||
}
|
||||
|
||||
addClient(reply: FastifyReply, sessionFilter: Set<string> | null, isRemote: boolean): void {
|
||||
this.sseClients.set(reply, sessionFilter);
|
||||
if (isRemote) {
|
||||
this.remoteSseClients.add(reply);
|
||||
}
|
||||
}
|
||||
|
||||
removeClient(reply: FastifyReply): void {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.delete(reply);
|
||||
this.backpressuredClients.delete(reply);
|
||||
}
|
||||
|
||||
/** Send a single SSE event to a specific client. */
|
||||
sendSSE(reply: FastifyReply, event: string, data: unknown): void {
|
||||
try {
|
||||
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
} catch {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.delete(reply);
|
||||
}
|
||||
}
|
||||
|
||||
/** Send pre-formatted tunnel padding to a specific client. */
|
||||
sendPadding(reply: FastifyReply): void {
|
||||
if (!this._isTunnelActive) return;
|
||||
try {
|
||||
reply.raw.write(SSE_PADDING);
|
||||
} catch {
|
||||
/* client gone */
|
||||
}
|
||||
}
|
||||
|
||||
// Optimized: send pre-formatted SSE message to a client
|
||||
// Returns false if client is backpressured or dead
|
||||
private sendSSEPreformatted(reply: FastifyReply, message: string): void {
|
||||
// Skip backpressured clients to prevent unbounded memory growth.
|
||||
// Terminal data dropped here is recovered via session:needsRefresh on drain.
|
||||
if (this.backpressuredClients.has(reply)) return;
|
||||
|
||||
try {
|
||||
const ok = reply.raw.write(message);
|
||||
if (!ok) {
|
||||
// Buffer is full — mark as backpressured, resume on drain
|
||||
this.backpressuredClients.add(reply);
|
||||
reply.raw.once('drain', () => {
|
||||
this.backpressuredClients.delete(reply);
|
||||
// Client may have missed terminal data during backpressure.
|
||||
// Tell it to reload the active session's buffer to recover.
|
||||
try {
|
||||
const drainPadding = this._isTunnelActive ? SSE_PADDING : '';
|
||||
reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n${drainPadding}`);
|
||||
} catch {
|
||||
/* client gone */
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.delete(reply);
|
||||
this.backpressuredClients.delete(reply);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Broadcasting ==========
|
||||
|
||||
broadcast(event: string, data: unknown): void {
|
||||
// Skip serialization entirely when no clients are listening
|
||||
if (this.sseClients.size === 0) return;
|
||||
|
||||
// Performance optimization: serialize JSON once for all clients.
|
||||
// Only append Cloudflare tunnel padding for latency-sensitive events —
|
||||
// Recovery events need immediate proxy flush; low-frequency metadata events
|
||||
// (session:created, ralph:*, respawn:*, etc.) don't need padding.
|
||||
// Note: session:terminal has its own padding in flushSessionTerminalBatch().
|
||||
const needsPadding = this._isTunnelActive && event === SseEvent.SessionNeedsRefresh;
|
||||
const padding = needsPadding ? SSE_PADDING : '';
|
||||
let message: string;
|
||||
try {
|
||||
message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + padding;
|
||||
} catch (err) {
|
||||
// Handle circular references or non-serializable values
|
||||
console.error(`[Server] Failed to serialize SSE event "${event}":`, err);
|
||||
return;
|
||||
}
|
||||
// Extract sessionId from event data for subscription filtering.
|
||||
const eventSessionId = this.extractSessionId(event, data);
|
||||
|
||||
for (const [client, filter] of this.sseClients) {
|
||||
// No filter (null) = receive everything. Otherwise, skip if event is
|
||||
// session-scoped and the session isn't in the client's subscription set.
|
||||
if (filter && eventSessionId && !filter.has(eventSessionId)) continue;
|
||||
this.sendSSEPreformatted(client, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the session ID from an event's data payload for subscription filtering.
|
||||
* Returns the sessionId string if the event is session-scoped, or null for global events.
|
||||
*/
|
||||
private extractSessionId(event: string, data: unknown): string | null {
|
||||
if (data == null || typeof data !== 'object') return null;
|
||||
const record = data as Record<string, unknown>;
|
||||
|
||||
// Most session-scoped events use `sessionId`
|
||||
if (typeof record.sessionId === 'string') return record.sessionId;
|
||||
|
||||
// Session lifecycle events (session:*) use `id` from the session state object
|
||||
if (typeof record.id === 'string' && event.startsWith('session:')) return record.id;
|
||||
|
||||
// No session ID found — treat as global event (sent to all clients)
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========== Terminal Data Batching ==========
|
||||
|
||||
// Batch terminal data for better performance (60fps)
|
||||
// Uses per-session timers with adaptive intervals to prevent thundering herd:
|
||||
// each session flushes independently rather than all sessions flushing in one burst.
|
||||
batchTerminalData(sessionId: string, data: string): void {
|
||||
// Skip if server is stopping
|
||||
if (this._isStopping) return;
|
||||
|
||||
let chunks = this.terminalBatches.get(sessionId);
|
||||
if (!chunks) {
|
||||
chunks = [];
|
||||
this.terminalBatches.set(sessionId, chunks);
|
||||
}
|
||||
chunks.push(data);
|
||||
const prevSize = this.terminalBatchSizes.get(sessionId) ?? 0;
|
||||
const totalLength = prevSize + data.length;
|
||||
this.terminalBatchSizes.set(sessionId, totalLength);
|
||||
|
||||
// Adaptive batching: detect rapid events and extend batch window (per-session)
|
||||
const now = Date.now();
|
||||
const lastEvent = this.lastTerminalEventTime.get(sessionId) ?? 0;
|
||||
const eventGap = now - lastEvent;
|
||||
this.lastTerminalEventTime.set(sessionId, now);
|
||||
|
||||
// Adjust batch interval based on event frequency (per-session)
|
||||
// Rapid events (<10ms gap) = 50ms batch, moderate (<20ms) = 32ms, else 16ms
|
||||
let sessionInterval: number;
|
||||
if (eventGap > 0 && eventGap < 10) {
|
||||
sessionInterval = 50;
|
||||
} else if (eventGap > 0 && eventGap < 20) {
|
||||
sessionInterval = 32;
|
||||
} else {
|
||||
sessionInterval = TERMINAL_BATCH_INTERVAL;
|
||||
}
|
||||
|
||||
// Flush immediately if batch is large for responsiveness
|
||||
if (totalLength > BATCH_FLUSH_THRESHOLD) {
|
||||
const existingTimer = this.terminalBatchTimers.get(sessionId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
this.terminalBatchTimers.delete(sessionId);
|
||||
}
|
||||
this.flushSessionTerminalBatch(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start per-session batch timer if not already running
|
||||
// Each session flushes independently — prevents one busy session from
|
||||
// forcing all sessions to flush at its rate (thundering herd)
|
||||
if (!this.terminalBatchTimers.has(sessionId)) {
|
||||
this.terminalBatchTimers.set(
|
||||
sessionId,
|
||||
setTimeout(() => {
|
||||
this.terminalBatchTimers.delete(sessionId);
|
||||
this.flushSessionTerminalBatch(sessionId);
|
||||
}, sessionInterval)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Flush a single session's batched terminal data */
|
||||
private flushSessionTerminalBatch(sessionId: string): void {
|
||||
if (this._isStopping) {
|
||||
this.terminalBatches.delete(sessionId);
|
||||
this.terminalBatchSizes.delete(sessionId);
|
||||
return;
|
||||
}
|
||||
const chunks = this.terminalBatches.get(sessionId);
|
||||
if (chunks && chunks.length > 0) {
|
||||
// Join chunks only at flush time (avoids O(n^2) string concatenation in batchTerminalData)
|
||||
const data = chunks.join('');
|
||||
// Wrap batched output in DEC 2026 synchronized output markers so xterm.js
|
||||
// renders the entire batch atomically. Ink spinner frames (cursor-up + redraw)
|
||||
// do NOT emit their own 2026 markers, so without this wrapper each partial
|
||||
// cursor update renders individually, causing visible flicker.
|
||||
// xterm.js 6.0+ handles DEC 2026 natively: it buffers everything between
|
||||
// 2026h/2026l and renders in one pass.
|
||||
const syncData = '\x1b[?2026h' + data + '\x1b[?2026l';
|
||||
// Fast path: build SSE message directly without JSON.stringify on wrapper object.
|
||||
// Only the terminal data string needs escaping; sessionId is a UUID (safe to template).
|
||||
const escapedData = JSON.stringify(syncData);
|
||||
// Append tunnel padding for immediate Cloudflare proxy flush —
|
||||
// terminal data is high-frequency and latency-sensitive.
|
||||
const padding = this._isTunnelActive ? SSE_PADDING : '';
|
||||
const message = `event: session:terminal\ndata: {"id":"${sessionId}","data":${escapedData}}\n\n` + padding;
|
||||
for (const [client, filter] of this.sseClients) {
|
||||
// Skip clients that have a session filter and aren't subscribed to this session
|
||||
if (filter && !filter.has(sessionId)) continue;
|
||||
this.sendSSEPreformatted(client, message);
|
||||
}
|
||||
}
|
||||
this.terminalBatches.delete(sessionId);
|
||||
this.terminalBatchSizes.delete(sessionId);
|
||||
}
|
||||
|
||||
// ========== Task Update Batching ==========
|
||||
|
||||
// Batch task:updated events at 100ms - only send latest update per task
|
||||
// Key is sessionId:taskId to avoid collisions when multiple tasks update concurrently
|
||||
batchTaskUpdate(sessionId: string, task: BackgroundTask): void {
|
||||
// Skip if server is stopping
|
||||
if (this._isStopping) return;
|
||||
|
||||
// Use composite key to avoid losing updates when multiple tasks update in same batch window
|
||||
const key = `${sessionId}:${task.id}`;
|
||||
this.taskUpdateBatches.set(key, { sessionId, task });
|
||||
|
||||
if (!this.taskUpdateBatchTimerId) {
|
||||
this.taskUpdateBatchTimerId = this.cleanup.setTimeout(
|
||||
() => {
|
||||
this.taskUpdateBatchTimerId = null;
|
||||
this.flushTaskUpdateBatches();
|
||||
},
|
||||
TASK_UPDATE_BATCH_INTERVAL,
|
||||
{ description: 'task update batch flush' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private flushTaskUpdateBatches(): void {
|
||||
// Skip if server is stopping (timer may have been queued before stop() was called)
|
||||
if (this._isStopping) {
|
||||
this.taskUpdateBatches.clear();
|
||||
return;
|
||||
}
|
||||
for (const [, { sessionId, task }] of this.taskUpdateBatches) {
|
||||
this.broadcast(SseEvent.TaskUpdated, { sessionId, task });
|
||||
}
|
||||
this.taskUpdateBatches.clear();
|
||||
}
|
||||
|
||||
// ========== Session State Batching ==========
|
||||
|
||||
/**
|
||||
* Debounce expensive session:updated broadcasts.
|
||||
* Instead of calling toDetailedState() on every event, batch requests
|
||||
* and only serialize once per STATE_UPDATE_DEBOUNCE_INTERVAL.
|
||||
*/
|
||||
broadcastSessionStateDebounced(sessionId: string): void {
|
||||
// Skip if server is stopping
|
||||
if (this._isStopping) return;
|
||||
|
||||
this.stateUpdatePending.add(sessionId);
|
||||
|
||||
if (!this.stateUpdateTimerId) {
|
||||
this.stateUpdateTimerId = this.cleanup.setTimeout(
|
||||
() => {
|
||||
this.stateUpdateTimerId = null;
|
||||
this.flushStateUpdates();
|
||||
},
|
||||
STATE_UPDATE_DEBOUNCE_INTERVAL,
|
||||
{ description: 'state update debounce flush' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private flushStateUpdates(): void {
|
||||
// Skip if server is stopping (timer may have been queued before stop() was called)
|
||||
if (this._isStopping) {
|
||||
this.stateUpdatePending.clear();
|
||||
return;
|
||||
}
|
||||
for (const sessionId of this.stateUpdatePending) {
|
||||
// Single expensive serialization per batch interval
|
||||
const state = this.deps.getSessionStateWithRespawn(sessionId);
|
||||
if (state) {
|
||||
this.broadcast(SseEvent.SessionUpdated, state);
|
||||
}
|
||||
}
|
||||
this.stateUpdatePending.clear();
|
||||
}
|
||||
|
||||
// ========== Client Health ==========
|
||||
|
||||
/**
|
||||
* Clean up dead SSE clients and send keep-alive comments.
|
||||
* Keep-alive prevents proxy/load-balancer timeouts on idle connections.
|
||||
* Dead client cleanup prevents memory leaks from abruptly terminated connections.
|
||||
*/
|
||||
cleanupDeadClients(): void {
|
||||
const deadClients: FastifyReply[] = [];
|
||||
|
||||
for (const [client] of this.sseClients) {
|
||||
try {
|
||||
// Check if the underlying socket is still writable
|
||||
const socket = client.raw.socket;
|
||||
if (!socket || socket.destroyed || !socket.writable) {
|
||||
deadClients.push(client);
|
||||
} else {
|
||||
// Send SSE comment as keep-alive. Only add padding when tunnel is
|
||||
// active — it flushes Cloudflare proxy buffers but wastes bandwidth
|
||||
// for direct/Tailscale connections.
|
||||
const ka = this._isTunnelActive ? ':keepalive\n' + SSE_PADDING : ':keepalive\n\n';
|
||||
client.raw.write(ka);
|
||||
}
|
||||
} catch {
|
||||
// Error accessing socket means client is dead
|
||||
deadClients.push(client);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove dead clients
|
||||
for (const client of deadClients) {
|
||||
this.sseClients.delete(client);
|
||||
this.remoteSseClients.delete(client);
|
||||
this.backpressuredClients.delete(client);
|
||||
}
|
||||
|
||||
if (deadClients.length > 0) {
|
||||
console.log(`[Server] Cleaned up ${deadClients.length} dead SSE client(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Session Cleanup ==========
|
||||
|
||||
/** Clean up all batching state for a session (call on session exit or deletion). */
|
||||
cleanupSessionBatches(sessionId: string): void {
|
||||
this.terminalBatches.delete(sessionId);
|
||||
this.terminalBatchSizes.delete(sessionId);
|
||||
const batchTimer = this.terminalBatchTimers.get(sessionId);
|
||||
if (batchTimer) {
|
||||
clearTimeout(batchTimer);
|
||||
this.terminalBatchTimers.delete(sessionId);
|
||||
}
|
||||
this.taskUpdateBatches.delete(sessionId);
|
||||
this.stateUpdatePending.delete(sessionId);
|
||||
this.lastTerminalEventTime.delete(sessionId);
|
||||
}
|
||||
|
||||
// ========== Lifecycle ==========
|
||||
|
||||
setStopping(): void {
|
||||
this._isStopping = true;
|
||||
}
|
||||
|
||||
/** Graceful shutdown: notify clients, close connections, clear all state. */
|
||||
stop(): void {
|
||||
this._isStopping = true;
|
||||
|
||||
// Gracefully close all SSE connections before clearing
|
||||
for (const [client] of this.sseClients) {
|
||||
try {
|
||||
// Send a final event to notify clients of shutdown
|
||||
this.sendSSE(client, 'server:shutdown', { reason: 'Server stopping' });
|
||||
client.raw.end();
|
||||
} catch {
|
||||
// Client may already be disconnected
|
||||
}
|
||||
}
|
||||
this.sseClients.clear();
|
||||
this.remoteSseClients.clear();
|
||||
this.backpressuredClients.clear();
|
||||
|
||||
// Clear per-session batch timers
|
||||
for (const timer of this.terminalBatchTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.terminalBatchTimers.clear();
|
||||
this.terminalBatches.clear();
|
||||
this.terminalBatchSizes.clear();
|
||||
|
||||
this.taskUpdateBatches.clear();
|
||||
this.stateUpdatePending.clear();
|
||||
|
||||
// Dispose StaleExpirationMap (stops internal cleanup timer)
|
||||
this.lastTerminalEventTime.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -18,20 +18,20 @@ Validates Codeman's mobile UI across 135 devices, covering:
|
||||
|
||||
```bash
|
||||
# Run all mobile tests
|
||||
npx vitest run --config mobile-test/vitest.config.ts
|
||||
npx vitest run --config test/mobile/vitest.config.ts
|
||||
|
||||
# Run a single test file
|
||||
npx vitest run --config mobile-test/vitest.config.ts mobile-test/keyboard.test.ts
|
||||
npx vitest run --config test/mobile/vitest.config.ts test/mobile/keyboard.test.ts
|
||||
|
||||
# Quick mode — 6 representative devices, skip full matrix
|
||||
CI_QUICK=1 npx vitest run --config mobile-test/vitest.config.ts
|
||||
CI_QUICK=1 npx vitest run --config test/mobile/vitest.config.ts
|
||||
|
||||
# Full device matrix only (135 devices)
|
||||
npx vitest run --config mobile-test/vitest.config.ts mobile-test/device-matrix.test.ts
|
||||
npx vitest run --config test/mobile/vitest.config.ts test/mobile/device-matrix.test.ts
|
||||
|
||||
# Update visual baselines (delete old baselines, re-run)
|
||||
rm -rf mobile-test/snapshots/*.png
|
||||
npx vitest run --config mobile-test/vitest.config.ts mobile-test/visual-regression.test.ts
|
||||
rm -rf test/mobile/snapshots/*.png
|
||||
npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regression.test.ts
|
||||
```
|
||||
|
||||
## Test Files
|
||||
@@ -126,7 +126,7 @@ Swipe simulation includes intermediate move points and timing to satisfy `SwipeH
|
||||
|
||||
Uses `pixelmatch` + `pngjs` (both in devDeps) for pixel-level comparison:
|
||||
|
||||
- **Baselines** stored in `mobile-test/snapshots/` (git-tracked)
|
||||
- **Baselines** stored in `test/mobile/snapshots/` (git-tracked)
|
||||
- **Tolerance**: 0.5% pixel diff (handles anti-aliasing)
|
||||
- **On failure**: `.actual.png` and `.diff.png` generated (gitignored)
|
||||
- **Update**: Delete baseline PNGs and re-run — new baselines auto-created
|
||||
@@ -96,7 +96,7 @@ export const SUBAGENT = {
|
||||
export const VISUAL = {
|
||||
DEFAULT_THRESHOLD: 0.1,
|
||||
MAX_DIFF_PERCENT: 0.5,
|
||||
SNAPSHOT_DIR: 'mobile-test/snapshots',
|
||||
SNAPSHOT_DIR: 'test/mobile/snapshots',
|
||||
} as const;
|
||||
|
||||
// Visual regression breakpoints (width values for screenshot comparison)
|
||||
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |