Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d072e773d8 | ||
|
|
a649c91b68 | ||
|
|
3383c23099 | ||
|
|
c3e1e731ef | ||
|
|
405b711c3a | ||
|
|
4295faefc9 | ||
|
|
93e1ba5110 | ||
|
|
abbbf9e90a | ||
|
|
3a41de7b57 | ||
|
|
8267edc6fe | ||
|
|
78c568e5f7 | ||
|
|
cc624d2575 | ||
|
|
5844720525 | ||
|
|
393a2d9c28 | ||
|
|
809bf6a614 | ||
|
|
da71d8d01c | ||
|
|
e5aca6aa4c | ||
|
|
ceaf4624a1 | ||
|
|
a6597e4a9a | ||
|
|
f869e823af | ||
|
|
8d0b179f94 | ||
|
|
98fa55b7b2 | ||
|
|
c46ac30631 | ||
|
|
dfcc14bfd2 | ||
|
|
a068008409 | ||
|
|
0aa31f100e | ||
|
|
314a160458 | ||
|
|
e7ee5595c5 | ||
|
|
625d4976d3 | ||
|
|
d02cddece6 | ||
|
|
abbc4b13fd | ||
|
|
a14e47e19c | ||
|
|
754a966b53 | ||
|
|
28dfc279d4 | ||
|
|
da85e9738b | ||
|
|
8b8907c4ec | ||
|
|
2329dab240 | ||
|
|
31ce7405a6 | ||
|
|
06f7d40c42 | ||
|
|
05eba70598 | ||
|
|
d27974ff6e | ||
|
|
3cca5380ba | ||
|
|
6d7efc13e6 | ||
|
|
63f86807ad | ||
|
|
2e4e646c06 | ||
|
|
507423b776 | ||
|
|
67d0b0b538 | ||
|
|
26cfd8b7ef | ||
|
|
208e6bc175 | ||
|
|
6d52b16edc | ||
|
|
4988e85901 |
@@ -1,9 +1,91 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add CJK IME input textarea for xterm.js terminal (env toggle INPUT_CJK_FORM=ON). Always-visible textarea below terminal handles native browser IME composition, forwarding completed text to PTY on Enter. Supports arrow keys, Ctrl combos, backspace passthrough, and Escape to clear.
|
||||
|
||||
Add fork installation support to install.sh with CODEMAN_REPO_URL and CODEMAN_BRANCH env vars, allowing custom repository and branch for git clone/update operations. README updated with fork installation instructions.
|
||||
|
||||
Fix WebSocket session lifecycle: close WS connections when session exits (prevents orphaned listeners and stale writes to dead PTY), add readyState guard in onTerminal to stop buffering after socket closes, simplify heartbeat by removing redundant alive flag.
|
||||
|
||||
Add WebSocket reconnection with exponential backoff (1s-10s) on unexpected close, skipping server rejection codes (4004/4008/4009). Falls back gracefully to SSE+POST during reconnection.
|
||||
|
||||
Clear CJK textarea on session switch to prevent sending stale text to wrong session.
|
||||
|
||||
## 0.3.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add WebSocket terminal I/O with server-side DEC 2026 synchronized update markers. Replaces per-keystroke HTTP POST + SSE terminal output with a single bidirectional WebSocket connection for dramatically lower input latency. Server-side 8ms micro-batching with 16KB flush threshold groups rapid PTY events into single WS frames wrapped in DEC 2026 markers for flicker-free atomic rendering. Includes 30s ping/pong heartbeat with 10s timeout for stale connection detection through tunnels. Existing SSE + HTTP POST paths remain fully functional as transparent fallback. Resize messages validated to match HTTP route bounds (cols 1-500, rows 1-200, integers only). 16 automated route tests added for WS endpoint. Also patches 5 dependency vulnerabilities (basic-ftp, fastify, minimatch, serialize-javascript).
|
||||
|
||||
## 0.3.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ### Session Resume & History
|
||||
- Add `resumeSessionId` support for conversation resume after reboot
|
||||
- Add history session resume UI and API with route shell sessions routing fix
|
||||
- Improve session resume reliability and persist user settings across refresh
|
||||
- Correct `claudeSessionId` for resumed sessions
|
||||
|
||||
### Terminal & Frontend
|
||||
- Upgrade xterm.js 5.3 → 6.0 with native DEC 2026 synchronized output
|
||||
- Increase terminal scrollback from 5,000 to 20,000 lines
|
||||
- Reduce default font size and persist tab state across refresh
|
||||
- Resolve terminal resize scrollback ghost renders
|
||||
- Hide subagent monitor panel by default
|
||||
|
||||
### Installer
|
||||
- Auto-detect existing install and run update instead of fresh install
|
||||
- Auto-restart codeman-web service after update if running
|
||||
- Show restart command when codeman-web is not a systemd service
|
||||
- Fix one-liner restart command for background processes
|
||||
|
||||
### Codebase Quality
|
||||
- Remove dead code, consolidate imports, extract constants
|
||||
- Repair 15 pre-existing subagent-watcher test failures
|
||||
- Clean up DEC sync dead code
|
||||
|
||||
## 0.3.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- - feat: upgrade xterm.js from 5.3 to 6.0 with native DEC 2026 synchronized output support
|
||||
- feat: add history session resume UI and API — resume Claude conversations after reboot
|
||||
- feat: add resumeSessionId support for conversation resume across session restarts
|
||||
- feat: persist active tabs across page refresh
|
||||
- feat: improve session resume reliability and persist user settings
|
||||
- perf: increase terminal scrollback from 5,000 to 20,000 lines
|
||||
- fix: resolve terminal resize scrollback ghost renders
|
||||
- fix: route shell sessions to correct endpoint on tab click
|
||||
- fix: correct claudeSessionId for resumed sessions (use original Claude conversation ID)
|
||||
- fix: increase default desktop font size from 12 to 14
|
||||
- refactor: extract shared \_fetchHistorySessions() method to eliminate duplication
|
||||
- refactor: remove dead DEC 2026 sync code (extractSyncSegments, DEC_SYNC_START/END constants)
|
||||
|
||||
## 0.3.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add content-hash cache busting for static assets — build step now renames JS/CSS files with MD5 content hashes (e.g. app.js → app.94b71235.js) and rewrites index.html references. HTML served with Cache-Control: no-cache so browsers always revalidate and pick up new hashed filenames after deploys. Hashed assets keep immutable 1-year cache. Eliminates the need for manual hard refresh (Ctrl+Shift+R) after deployments.
|
||||
|
||||
Refactor path traversal validation into shared validatePathWithinBase() helper in route-helpers.ts, replacing 6 duplicate inline checks across case-routes, plan-routes, and session-routes.
|
||||
|
||||
Deduplicate stripAnsi in bash-tool-parser.ts — use shared utility from utils/index.ts instead of private method.
|
||||
|
||||
## 0.3.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add tunnel status indicator with control panel — green pulsing dot in header when Cloudflare tunnel is active, dropdown with URL, remote clients, auth sessions, and start/stop/QR/revoke controls
|
||||
|
||||
## 0.3.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Operation Lightspeed: 5 parallel performance optimizations — multi-layer backpressure to prevent terminal write freezes, TERMINAL_TAIL_SIZE constant with client-drop recovery, tab switching SSE gating, and local echo improvements
|
||||
- Codebase cleanup: remove dead code (unused token validation exports, PlanPhase alias), add execPattern() regex helper to eliminate repetitive .lastIndex resets, centralize 11 magic number constants into config files, fix CLAUDE.md inaccuracies, and add 316 new tests for utilities, respawn helpers, and system-routes
|
||||
|
||||
## 0.3.6
|
||||
|
||||
@@ -44,7 +44,7 @@ When user says "COM":
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
Description of changes
|
||||
Detailed description of ALL changes since last release (not just the most recent commit — review full git log since last version tag)
|
||||
CHANGESET
|
||||
```
|
||||
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
|
||||
@@ -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.3.7 (must match `package.json`)
|
||||
**Version**: 0.4.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -109,7 +109,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` (12 route modules + barrel), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` ★ (~12.1K lines) + 9 JS modules (incl. `sw.js` service worker) | |
|
||||
| **Frontend** | `src/web/public/app.js` ★ (~12.5K lines) + 9 JS modules (incl. `sw.js` service worker) | |
|
||||
| **Types** | `src/types/index.ts` → 13 domain files | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
|
||||
|
||||
@@ -28,18 +28,33 @@
|
||||
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
|
||||
```
|
||||
|
||||
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it. 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:
|
||||
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
|
||||
codeman web
|
||||
# Open http://localhost:3000 — press Ctrl+Enter to start your first session
|
||||
```
|
||||
|
||||
**Update to latest version:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash -s update
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>Run as a background service</strong></summary>
|
||||
|
||||
@@ -143,6 +158,10 @@ Watch background agents work in real-time. Codeman monitors agent activity and d
|
||||
|
||||
## Zero-Lag Input Overlay
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/zerolag-demo.gif" alt="Zerolag Demo — local echo vs server echo side-by-side" width="900">
|
||||
</p>
|
||||
|
||||
When accessing your coding agent remotely (VPN, Tailscale, SSH tunnel), every keystroke normally takes 200-300ms to round-trip. Codeman implements a **Mosh-inspired local echo system** that makes typing feel instant regardless of latency.
|
||||
|
||||
A pixel-perfect DOM overlay inside xterm.js renders keystrokes at 0ms. Background forwarding silently sends every character to the PTY in 50ms debounced batches, so Tab completion, `Ctrl+R` history search, and all shell features work normally. When the server echo arrives 200-300ms later, the overlay seamlessly disappears and the real terminal text takes over — the transition is invisible.
|
||||
|
||||
|
After Width: | Height: | Size: 806 KiB |
@@ -9,6 +9,8 @@
|
||||
# CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app)
|
||||
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd 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)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -17,7 +19,8 @@ set -euo pipefail
|
||||
# ============================================================================
|
||||
|
||||
INSTALL_DIR="${CODEMAN_INSTALL_DIR:-$HOME/.codeman/app}"
|
||||
REPO_URL="https://github.com/Ark0N/Codeman.git"
|
||||
REPO_URL="${CODEMAN_REPO_URL:-https://github.com/Ark0N/Codeman.git}"
|
||||
BRANCH="${CODEMAN_BRANCH:-master}"
|
||||
MIN_NODE_VERSION=18
|
||||
TARGET_NODE_VERSION="${CODEMAN_NODE_VERSION:-22}"
|
||||
NONINTERACTIVE="${CODEMAN_NONINTERACTIVE:-0}"
|
||||
@@ -1062,26 +1065,27 @@ main() {
|
||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||
info "Existing installation found, updating..."
|
||||
cd "$INSTALL_DIR"
|
||||
git remote set-url origin "$REPO_URL" 2>/dev/null || true
|
||||
|
||||
# Check for local changes
|
||||
if ! git diff --quiet 2>/dev/null || ! git diff --staged --quiet 2>/dev/null; then
|
||||
warn "Local changes detected in $INSTALL_DIR"
|
||||
if prompt_yes_no "Discard local changes and update?" "n"; then
|
||||
git fetch --quiet origin
|
||||
git reset --hard origin/master --quiet
|
||||
git reset --hard "origin/$BRANCH" --quiet
|
||||
else
|
||||
info "Keeping existing installation, skipping update"
|
||||
fi
|
||||
else
|
||||
git fetch --quiet origin
|
||||
git reset --hard origin/master --quiet
|
||||
git reset --hard "origin/$BRANCH" --quiet
|
||||
fi
|
||||
else
|
||||
# Create parent directory
|
||||
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||
|
||||
# Clone repository (shallow for speed)
|
||||
git clone --quiet --depth 1 "$REPO_URL" "$INSTALL_DIR"
|
||||
git clone --quiet --depth 1 --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR"
|
||||
cd "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
@@ -1277,13 +1281,23 @@ update() {
|
||||
|
||||
info "Updating Codeman..."
|
||||
cd "$INSTALL_DIR"
|
||||
git remote set-url origin "$REPO_URL" 2>/dev/null || true
|
||||
git fetch --quiet origin
|
||||
git reset --hard origin/master --quiet
|
||||
git reset --hard "origin/$BRANCH" --quiet
|
||||
npm install --quiet --no-fund --no-audit 2>/dev/null || npm install --no-fund --no-audit
|
||||
npm run build --quiet 2>/dev/null || npm run build
|
||||
success "Updated to $(node -e "console.log(require('./package.json').version)")"
|
||||
echo ""
|
||||
echo -e " ${DIM}Restart codeman web to use the new version.${NC}"
|
||||
|
||||
# Auto-restart systemd service if it's running, otherwise tell the user
|
||||
if systemctl --user is-active codeman-web.service &>/dev/null; then
|
||||
info "Restarting codeman-web service..."
|
||||
systemctl --user restart codeman-web.service
|
||||
success "codeman-web service restarted"
|
||||
else
|
||||
echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
|
||||
echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}"
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
@@ -1355,5 +1369,12 @@ uninstall() {
|
||||
case "${1:-}" in
|
||||
update) update ;;
|
||||
uninstall) uninstall ;;
|
||||
*) main "$@" ;;
|
||||
*)
|
||||
if [[ -z "${1:-}" && -d "$INSTALL_DIR/.git" ]]; then
|
||||
print_banner
|
||||
update
|
||||
else
|
||||
main "$@"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.11",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -17,8 +17,11 @@
|
||||
"@fastify/compress": "^8.3.1",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/static": "^8.0.0",
|
||||
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
|
||||
"@rspack/binding-linux-x64-gnu": "^1.7.7",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"chalk": "^5.3.0",
|
||||
"chokidar": "^3.6.0",
|
||||
"commander": "^12.1.0",
|
||||
@@ -27,10 +30,6 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^10.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"xterm-addon-unicode11": "^0.6.0",
|
||||
"xterm-addon-webgl": "^0.16.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"bin": {
|
||||
@@ -47,6 +46,7 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"agent-browser": "^0.6.0",
|
||||
"esbuild": "^0.27.3",
|
||||
@@ -64,6 +64,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
|
||||
"@rspack/binding-linux-x64-gnu": "^1.7.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
@@ -943,6 +947,53 @@
|
||||
"glob": "^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/websocket": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
|
||||
"integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"duplexify": "^4.1.3",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/websocket/node_modules/duplexify": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
|
||||
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.4.1",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1",
|
||||
"stream-shift": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/websocket/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"dev": true,
|
||||
@@ -1432,6 +1483,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
@@ -1861,6 +1913,7 @@
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
@@ -2114,6 +2167,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"dev": true,
|
||||
@@ -2599,6 +2662,33 @@
|
||||
"@xtuc/long": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-unicode11": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
|
||||
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/@xtuc/ieee754": {
|
||||
"version": "1.2.0",
|
||||
"dev": true,
|
||||
@@ -2988,7 +3078,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/basic-ftp": {
|
||||
"version": "5.1.0",
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",
|
||||
"integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4385,7 +4477,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fastify": {
|
||||
"version": "5.7.4",
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.2.tgz",
|
||||
"integrity": "sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -4407,7 +4501,7 @@
|
||||
"fast-json-stringify": "^6.0.0",
|
||||
"find-my-way": "^9.0.0",
|
||||
"light-my-request": "^6.0.0",
|
||||
"pino": "^10.1.0",
|
||||
"pino": "^9.14.0 || ^10.1.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"rfdc": "^1.3.1",
|
||||
"secure-json-parse": "^4.0.0",
|
||||
@@ -4570,6 +4664,20 @@
|
||||
"dev": true,
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"dev": true,
|
||||
@@ -5610,7 +5718,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.2",
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
@@ -6132,6 +6242,21 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "7.0.0",
|
||||
"dev": true,
|
||||
@@ -6594,14 +6719,6 @@
|
||||
"version": "4.0.4",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"dev": true,
|
||||
@@ -6992,14 +7109,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/serialize-javascript": {
|
||||
"version": "6.0.2",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"randombytes": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"license": "ISC"
|
||||
@@ -7370,14 +7479,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/terser-webpack-plugin": {
|
||||
"version": "5.3.16",
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz",
|
||||
"integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"jest-worker": "^27.4.5",
|
||||
"schema-utils": "^4.3.0",
|
||||
"serialize-javascript": "^6.0.2",
|
||||
"terser": "^5.31.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -8457,7 +8567,6 @@
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -8495,31 +8604,6 @@
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm": {
|
||||
"version": "5.3.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xterm-addon-fit": {
|
||||
"version": "0.8.0",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm-addon-unicode11": {
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm-addon-webgl": {
|
||||
"version": "0.16.0",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm-zerolag-input": {
|
||||
"resolved": "packages/xterm-zerolag-input",
|
||||
"link": true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.3.7",
|
||||
"version": "0.4.0",
|
||||
"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",
|
||||
@@ -51,8 +51,11 @@
|
||||
"@fastify/compress": "^8.3.1",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/static": "^8.0.0",
|
||||
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
|
||||
"@rspack/binding-linux-x64-gnu": "^1.7.7",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"chalk": "^5.3.0",
|
||||
"chokidar": "^3.6.0",
|
||||
"commander": "^12.1.0",
|
||||
@@ -61,10 +64,6 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^10.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"xterm-addon-unicode11": "^0.6.0",
|
||||
"xterm-addon-webgl": "^0.16.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -78,6 +77,7 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"agent-browser": "^0.6.0",
|
||||
"esbuild": "^0.27.3",
|
||||
@@ -93,6 +93,10 @@
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
|
||||
"@rspack/binding-linux-x64-gnu": "^1.7.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
|
||||
@@ -8,13 +8,15 @@
|
||||
* 2. Copy static assets (web/public, templates)
|
||||
* 3. Build vendor xterm bundles
|
||||
* 4. Minify frontend assets (app.js, styles.css, mobile.css)
|
||||
* 5. Compress with gzip + brotli
|
||||
* 5. Content-hash cache busting (rename assets, rewrite index.html)
|
||||
* 6. Compress with gzip + brotli
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { appendFileSync } from 'fs';
|
||||
import { appendFileSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
||||
import { createHash } from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { join } from 'path';
|
||||
import { join, extname, basename, dirname } from 'path';
|
||||
|
||||
const ROOT = join(fileURLToPath(import.meta.url), '..', '..');
|
||||
|
||||
@@ -27,17 +29,18 @@ function run(label, cmd) {
|
||||
run('tsc', 'tsc');
|
||||
run('chmod dist/index.js', 'chmod +x dist/index.js');
|
||||
|
||||
// 2. Copy static assets
|
||||
// 2. Copy static assets (clean first to remove stale hashed files from previous builds)
|
||||
run('clean public', 'rm -rf dist/web/public');
|
||||
run('prepare dirs', 'mkdir -p dist/web dist/templates dist/web/public/vendor');
|
||||
run('copy web assets', 'cp -r src/web/public dist/web/');
|
||||
run('copy template', 'cp src/templates/case-template.md dist/templates/');
|
||||
|
||||
// 3. Vendor xterm bundles
|
||||
run('xterm css', 'cp node_modules/xterm/css/xterm.css dist/web/public/vendor/');
|
||||
run('xterm js', 'npx esbuild node_modules/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
|
||||
run('xterm-addon-fit', 'npx esbuild node_modules/xterm-addon-fit/lib/xterm-addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
|
||||
run('xterm-addon-webgl', 'cp node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
|
||||
run('xterm-addon-unicode11', 'npx esbuild node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
|
||||
// 3. Vendor xterm bundles (xterm.js 6.x — @xterm scoped packages)
|
||||
run('xterm css', 'cp node_modules/@xterm/xterm/css/xterm.css dist/web/public/vendor/');
|
||||
run('xterm js', 'npx esbuild node_modules/@xterm/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
|
||||
run('xterm-addon-fit', 'npx esbuild node_modules/@xterm/addon-fit/lib/addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
|
||||
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
|
||||
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
|
||||
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
|
||||
|
||||
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`
|
||||
@@ -56,11 +59,55 @@ appendFileSync(
|
||||
);
|
||||
|
||||
// 4. Minify frontend assets
|
||||
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
|
||||
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
|
||||
run('minify styles.css', 'npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite');
|
||||
run('minify mobile.css', 'npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite');
|
||||
|
||||
// 5. Compress with gzip + brotli
|
||||
// 5. Content-hash cache busting
|
||||
console.log('\n[build] content-hash cache busting');
|
||||
{
|
||||
const distPublic = join(ROOT, 'dist/web/public');
|
||||
const HASHABLE = [
|
||||
'styles.css',
|
||||
'mobile.css',
|
||||
'constants.js',
|
||||
'mobile-handlers.js',
|
||||
'voice-input.js',
|
||||
'notification-manager.js',
|
||||
'keyboard-accessory.js',
|
||||
'input-cjk.js',
|
||||
'app.js',
|
||||
'ralph-wizard.js',
|
||||
'api-client.js',
|
||||
'subagent-windows.js',
|
||||
'vendor/xterm-zerolag-input.js',
|
||||
];
|
||||
const manifest = {};
|
||||
for (const file of HASHABLE) {
|
||||
const filePath = join(distPublic, file);
|
||||
const content = readFileSync(filePath);
|
||||
const hash = createHash('md5').update(content).digest('hex').slice(0, 8);
|
||||
const ext = extname(file);
|
||||
const base = basename(file, ext);
|
||||
const dir = dirname(file);
|
||||
const hashed = dir === '.' ? `${base}.${hash}${ext}` : `${dir}/${base}.${hash}${ext}`;
|
||||
renameSync(filePath, join(distPublic, hashed));
|
||||
manifest[file] = hashed;
|
||||
}
|
||||
// Rewrite index.html to reference hashed filenames
|
||||
let html = readFileSync(join(distPublic, 'index.html'), 'utf8');
|
||||
for (const [original, hashed] of Object.entries(manifest)) {
|
||||
html = html.replaceAll(`"${original}"`, `"${hashed}"`);
|
||||
}
|
||||
writeFileSync(join(distPublic, 'index.html'), html);
|
||||
console.log(' Hashed files:');
|
||||
for (const [orig, hashed] of Object.entries(manifest)) {
|
||||
console.log(` ${orig} -> ${hashed}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Compress with gzip + brotli
|
||||
run(
|
||||
'compress',
|
||||
`for f in dist/web/public/*.js dist/web/public/*.css dist/web/public/*.html dist/web/public/vendor/*.js dist/web/public/vendor/*.css; do` +
|
||||
|
||||
@@ -250,10 +250,10 @@ if (isGlobalInstall) {
|
||||
} else {
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const xtermDir = join(require.resolve('xterm'), '..', '..');
|
||||
const fitDir = join(require.resolve('xterm-addon-fit'), '..', '..');
|
||||
const webglDir = join(require.resolve('xterm-addon-webgl'), '..', '..');
|
||||
const unicode11Dir = join(require.resolve('xterm-addon-unicode11'), '..', '..');
|
||||
const xtermDir = join(require.resolve('@xterm/xterm'), '..', '..');
|
||||
const fitDir = join(require.resolve('@xterm/addon-fit'), '..', '..');
|
||||
const webglDir = join(require.resolve('@xterm/addon-webgl'), '..', '..');
|
||||
const unicode11Dir = join(require.resolve('@xterm/addon-unicode11'), '..', '..');
|
||||
const vendorDir = join(srcDir, 'web', 'public', 'vendor');
|
||||
|
||||
const { mkdirSync, copyFileSync } = await import('fs');
|
||||
@@ -263,19 +263,19 @@ if (isGlobalInstall) {
|
||||
// Minify xterm JS for dev vendor dir (npm packages don't ship .min.js)
|
||||
try {
|
||||
execSync(`npx esbuild "${join(xtermDir, 'lib', 'xterm.js')}" --minify --outfile="${join(vendorDir, 'xterm.min.js')}"`, { stdio: 'pipe' });
|
||||
execSync(`npx esbuild "${join(fitDir, 'lib', 'xterm-addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' });
|
||||
execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'xterm-addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' });
|
||||
execSync(`npx esbuild "${join(fitDir, 'lib', 'addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' });
|
||||
execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' });
|
||||
console.log(colors.green('✓ xterm vendor files copied to src/web/public/vendor/'));
|
||||
} catch {
|
||||
// Fallback: copy unminified
|
||||
copyFileSync(join(xtermDir, 'lib', 'xterm.js'), join(vendorDir, 'xterm.min.js'));
|
||||
copyFileSync(join(fitDir, 'lib', 'xterm-addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js'));
|
||||
copyFileSync(join(unicode11Dir, 'lib', 'xterm-addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js'));
|
||||
copyFileSync(join(fitDir, 'lib', 'addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js'));
|
||||
copyFileSync(join(unicode11Dir, 'lib', 'addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js'));
|
||||
console.log(colors.green('✓ xterm vendor files copied') + colors.dim(' (unminified — esbuild not available)'));
|
||||
}
|
||||
|
||||
// WebGL addon: copy unminified (matches build script behavior)
|
||||
copyFileSync(join(webglDir, 'lib', 'xterm-addon-webgl.js'), join(vendorDir, 'xterm-addon-webgl.min.js'));
|
||||
copyFileSync(join(webglDir, 'lib', 'addon-webgl.js'), join(vendorDir, 'xterm-addon-webgl.min.js'));
|
||||
|
||||
// xterm-zerolag-input: bundle local package as IIFE for <script> tag loading
|
||||
try {
|
||||
|
||||
@@ -28,8 +28,7 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { getAugmentedPath } from './utils/claude-cli-resolver.js';
|
||||
import { ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js';
|
||||
import { getAugmentedPath, ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js';
|
||||
import { AI_CHECK_MAX_BACKOFF_MS } from './config/ai-defaults.js';
|
||||
|
||||
// ========== Security Validation ==========
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ActiveBashTool } from './types.js';
|
||||
import { CleanupManager, Debouncer } from './utils/index.js';
|
||||
import { CleanupManager, Debouncer, stripAnsi } from './utils/index.js';
|
||||
|
||||
// ========== Configuration Constants ==========
|
||||
|
||||
@@ -462,7 +462,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
* Process a single line of terminal output (raw — will strip ANSI).
|
||||
*/
|
||||
private processLine(line: string): void {
|
||||
const cleanLine = this.stripAnsi(line);
|
||||
const cleanLine = stripAnsi(line);
|
||||
this.processCleanLine(cleanLine);
|
||||
}
|
||||
|
||||
@@ -668,15 +668,6 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
return this.deduplicatePaths(rawPaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI escape codes from a string.
|
||||
*/
|
||||
private stripAnsi(str: string): string {
|
||||
// Comprehensive ANSI pattern
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a debounced update emission.
|
||||
*/
|
||||
|
||||
@@ -56,11 +56,6 @@ export const TRIM_TEXT_TO = 768 * 1024; // 768KB
|
||||
*/
|
||||
export const MAX_MESSAGES = 1000;
|
||||
|
||||
/**
|
||||
* Number of messages to keep when trimming (80% of max).
|
||||
*/
|
||||
export const TRIM_MESSAGES_TO = 800;
|
||||
|
||||
// ============================================================================
|
||||
// Line Buffer Limits
|
||||
// ============================================================================
|
||||
@@ -85,3 +80,14 @@ export const MAX_RESPAWN_BUFFER_SIZE = 1 * 1024 * 1024; // 1MB
|
||||
* Size to trim respawn buffer to when max is exceeded.
|
||||
*/
|
||||
export const TRIM_RESPAWN_BUFFER_TO = 512 * 1024; // 512KB
|
||||
|
||||
// ============================================================================
|
||||
// File Peek Limits
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Maximum bytes to read when peeking at the beginning of a file.
|
||||
* Used with `createReadStream({ end })` (inclusive) to read the first 8KB,
|
||||
* which is enough to extract metadata from the first few JSONL lines.
|
||||
*/
|
||||
export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
|
||||
|
||||
@@ -11,11 +11,5 @@
|
||||
/** Max input length per API request (bytes) */
|
||||
export const MAX_INPUT_LENGTH = 64 * 1024;
|
||||
|
||||
/** Max terminal columns for resize requests */
|
||||
export const MAX_TERMINAL_COLS = 500;
|
||||
|
||||
/** Max terminal rows for resize requests */
|
||||
export const MAX_TERMINAL_ROWS = 200;
|
||||
|
||||
/** Max session name length (chars) */
|
||||
export const MAX_SESSION_NAME_LENGTH = 128;
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface CreateSessionOptions {
|
||||
claudeMode?: ClaudeMode;
|
||||
allowedTools?: string;
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
/** Options for respawning a dead pane. */
|
||||
@@ -73,6 +75,8 @@ export interface RespawnPaneOptions {
|
||||
claudeMode?: ClaudeMode;
|
||||
allowedTools?: string;
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** Resume a previous Claude conversation when respawning */
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -521,7 +521,7 @@ export class PlanOrchestrator {
|
||||
} finally {
|
||||
// Always clean up session and progress interval — centralizing here
|
||||
// prevents the race where cancel() and catch both try to manage the set
|
||||
await session.stop().catch(() => {});
|
||||
await session.stop().catch(() => {}); // Ignore - session cleanup is best-effort in finally block
|
||||
this.runningSessions.delete(session);
|
||||
clearInterval(progressInterval);
|
||||
}
|
||||
@@ -651,7 +651,7 @@ export class PlanOrchestrator {
|
||||
} finally {
|
||||
// Always clean up session and progress interval — centralizing here
|
||||
// prevents the race where cancel() and catch both try to manage the set
|
||||
await session.stop().catch(() => {});
|
||||
await session.stop().catch(() => {}); // Ignore - session cleanup is best-effort in finally block
|
||||
this.runningSessions.delete(session);
|
||||
clearInterval(progressInterval);
|
||||
}
|
||||
|
||||
@@ -49,8 +49,7 @@ import { Session } from './session.js';
|
||||
import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js';
|
||||
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
|
||||
import type { TeamWatcher } from './team-watcher.js';
|
||||
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
||||
import { ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
|
||||
import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
|
||||
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
|
||||
import {
|
||||
isCompletionMessage,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { ClaudeMode } from './types.js';
|
||||
import { getAugmentedPath } from './utils/claude-cli-resolver.js';
|
||||
import { getAugmentedPath } from './utils/index.js';
|
||||
|
||||
/**
|
||||
* Build Claude CLI permission flags based on the configured mode.
|
||||
|
||||
@@ -48,8 +48,8 @@ import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||
import { RalphTracker } from './ralph-tracker.js';
|
||||
import { BashToolParser } from './bash-tool-parser.js';
|
||||
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
||||
import {
|
||||
BufferAccumulator,
|
||||
ANSI_ESCAPE_PATTERN_FULL,
|
||||
TOKEN_PATTERN,
|
||||
SPINNER_PATTERN,
|
||||
@@ -324,6 +324,7 @@ export class Session extends EventEmitter {
|
||||
|
||||
// OpenCode configuration (only for mode === 'opencode')
|
||||
private _openCodeConfig: OpenCodeConfig | undefined;
|
||||
private _resumeSessionId: string | undefined;
|
||||
|
||||
// Session color for visual differentiation
|
||||
private _color: import('./types.js').SessionColor = 'default';
|
||||
@@ -382,6 +383,8 @@ export class Session extends EventEmitter {
|
||||
allowedTools?: string;
|
||||
/** OpenCode configuration (only for mode === 'opencode') */
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** Resume a previous Claude conversation (used after server reboot) */
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
) {
|
||||
super();
|
||||
@@ -398,12 +401,10 @@ export class Session extends EventEmitter {
|
||||
this.createdAt = config.createdAt || Date.now();
|
||||
this.mode = config.mode || 'claude';
|
||||
this._name = config.name || '';
|
||||
this._resumeSessionId = config.resumeSessionId;
|
||||
this._lastActivityAt = this.createdAt;
|
||||
// Set claudeSessionId immediately — Codeman always passes --session-id ${this.id}
|
||||
// to Claude CLI, so the Claude session ID always matches the Codeman session ID.
|
||||
// This ensures subagent matching works even for recovered sessions (where
|
||||
// startInteractive() hasn't been called yet).
|
||||
this._claudeSessionId = this.id;
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
this._claudeSessionId = config.resumeSessionId || this.id;
|
||||
this._mux = config.mux || null;
|
||||
this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable());
|
||||
this._muxSession = config.muxSession || null;
|
||||
@@ -792,6 +793,7 @@ export class Session extends EventEmitter {
|
||||
cliAccountType: this._cliAccountType || undefined,
|
||||
cliLatestVersion: this._cliLatestVersion || undefined,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -910,6 +912,7 @@ export class Session extends EventEmitter {
|
||||
claudeMode: this._claudeMode,
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
});
|
||||
if (!newPid) {
|
||||
console.error('[Session] Failed to respawn pane, will create new session');
|
||||
@@ -936,6 +939,7 @@ export class Session extends EventEmitter {
|
||||
claudeMode: this._claudeMode,
|
||||
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
|
||||
@@ -955,9 +959,8 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
);
|
||||
|
||||
// Set claudeSessionId immediately since we passed --session-id to Claude
|
||||
// The mux manager passes --session-id ${sessionId} to Claude
|
||||
this._claudeSessionId = this.id;
|
||||
// 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}`);
|
||||
@@ -1042,9 +1045,8 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Set the claudeSessionId immediately since we passed --session-id
|
||||
// This ensures subagent matching works without waiting for JSON messages
|
||||
this._claudeSessionId = this.id;
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
this._claudeSessionId = this._resumeSessionId || this.id;
|
||||
|
||||
this._pid = this.ptyProcess.pid;
|
||||
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* Tracks per-agent: status, token counts, model, description, tool call count, liveness (PID).
|
||||
*
|
||||
* @dependencies config/map-limits (MAX_TRACKED_AGENTS, PENDING_TOOL_CALL_TTL_MS),
|
||||
* utils (CleanupManager, KeyedDebouncer)
|
||||
* config/buffer-limits (FILE_PEEK_BYTES), utils (CleanupManager, KeyedDebouncer)
|
||||
* @consumedby web/server (SSE broadcast), session (subagent-session correlation)
|
||||
* @emits subagent:discovered, subagent:updated, subagent:tool_call, subagent:tool_result,
|
||||
* subagent:progress, subagent:message, subagent:completed
|
||||
@@ -35,6 +35,7 @@ import { execFile } from 'node:child_process';
|
||||
import { readFile, readdir, stat as statAsync } from 'node:fs/promises';
|
||||
import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS, MAX_TRACKED_AGENTS } from './config/map-limits.js';
|
||||
import { STALE_DATA_MAX_AGE_MS } from './config/server-timing.js';
|
||||
import { FILE_PEEK_BYTES } from './config/buffer-limits.js';
|
||||
import { CleanupManager, KeyedDebouncer } from './utils/index.js';
|
||||
|
||||
// ========== Types ==========
|
||||
@@ -1009,7 +1010,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
private async extractDescriptionFromFile(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
// Only read the first 8KB — more than enough for 5 JSONL lines
|
||||
const stream = createReadStream(filePath, { end: 8191 });
|
||||
const stream = createReadStream(filePath, { end: FILE_PEEK_BYTES });
|
||||
const rl = createInterface({ input: stream });
|
||||
|
||||
return await new Promise<string | undefined>((resolve) => {
|
||||
@@ -1141,10 +1142,10 @@ export class SubagentWatcher extends EventEmitter {
|
||||
|
||||
if (this.fileAgentContext.has(filePath)) {
|
||||
// Known file — handle content change
|
||||
this.handleFileChange(filePath).catch(() => {});
|
||||
this.handleFileChange(filePath).catch(() => {}); // Ignore - errors logged internally, don't crash watcher callback
|
||||
} else {
|
||||
// New file — register it
|
||||
this.registerAgentFile(filePath, projectHash, sessionId).catch(() => {});
|
||||
this.registerAgentFile(filePath, projectHash, sessionId).catch(() => {}); // Ignore - errors logged internally, don't crash watcher callback
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ export class TeamWatcher extends EventEmitter {
|
||||
persistent: false,
|
||||
});
|
||||
|
||||
const teamsHandler = () => this.pollAsync().catch(() => {});
|
||||
const teamsHandler = () => this.pollAsync().catch(() => {}); // Ignore - poll errors are non-fatal, next poll will retry
|
||||
this.teamsWatcher.on('add', teamsHandler);
|
||||
this.teamsWatcher.on('change', teamsHandler);
|
||||
this.teamsWatcher.on('unlink', teamsHandler);
|
||||
@@ -82,8 +82,8 @@ export class TeamWatcher extends EventEmitter {
|
||||
persistent: false,
|
||||
});
|
||||
|
||||
this.tasksWatcher.on('add', () => this.pollTasks().catch(() => {}));
|
||||
this.tasksWatcher.on('change', () => this.pollTasks().catch(() => {}));
|
||||
this.tasksWatcher.on('add', () => this.pollTasks().catch(() => {})); // Ignore - poll errors are non-fatal, next poll will retry
|
||||
this.tasksWatcher.on('change', () => this.pollTasks().catch(() => {})); // Ignore - poll errors are non-fatal, next poll will retry
|
||||
this.tasksWatcher.on('error', (err) => {
|
||||
console.warn('[TeamWatcher] chokidar tasks watcher error:', err);
|
||||
});
|
||||
@@ -95,11 +95,11 @@ export class TeamWatcher extends EventEmitter {
|
||||
stop(): void {
|
||||
// Close chokidar watchers
|
||||
if (this.teamsWatcher) {
|
||||
this.teamsWatcher.close().catch(() => {});
|
||||
this.teamsWatcher.close().catch(() => {}); // Ignore - watcher cleanup is best-effort during shutdown
|
||||
this.teamsWatcher = null;
|
||||
}
|
||||
if (this.tasksWatcher) {
|
||||
this.tasksWatcher.close().catch(() => {});
|
||||
this.tasksWatcher.close().catch(() => {}); // Ignore - watcher cleanup is best-effort during shutdown
|
||||
this.tasksWatcher = null;
|
||||
}
|
||||
if (this.pollTimer) {
|
||||
|
||||
@@ -40,8 +40,7 @@ import {
|
||||
type SessionMode,
|
||||
type OpenCodeConfig,
|
||||
} from './types.js';
|
||||
import { wrapWithNice } from './utils/nice-wrapper.js';
|
||||
import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js';
|
||||
import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir } from './utils/index.js';
|
||||
import type {
|
||||
TerminalMultiplexer,
|
||||
MuxSession,
|
||||
@@ -50,11 +49,6 @@ import type {
|
||||
RespawnPaneOptions,
|
||||
} from './mux-interface.js';
|
||||
|
||||
// Claude CLI PATH resolution — shared utility
|
||||
import { findClaudeDir } from './utils/claude-cli-resolver.js';
|
||||
// OpenCode CLI PATH resolution
|
||||
import { resolveOpenCodeDir } from './utils/opencode-cli-resolver.js';
|
||||
|
||||
// ============================================================================
|
||||
// Timing Constants
|
||||
// ============================================================================
|
||||
@@ -200,12 +194,24 @@ function buildSpawnCommand(options: {
|
||||
claudeMode?: ClaudeMode;
|
||||
allowedTools?: string;
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
resumeSessionId?: string;
|
||||
}): 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}` : '';
|
||||
return `claude${buildClaudePermissionFlags(options.claudeMode, options.allowedTools)} --session-id "${options.sessionId}"${modelFlag}`;
|
||||
// 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.
|
||||
const safeResumeId =
|
||||
options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined;
|
||||
const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools);
|
||||
if (safeResumeId) {
|
||||
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}`;
|
||||
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`;
|
||||
return `${resumeCmd} || ${fallbackCmd}`;
|
||||
}
|
||||
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`;
|
||||
}
|
||||
if (options.mode === 'opencode') {
|
||||
return buildOpenCodeCommand(options.openCodeConfig);
|
||||
@@ -370,7 +376,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* In test mode: creates an in-memory session only (no real tmux session).
|
||||
*/
|
||||
async createSession(options: CreateSessionOptions): Promise<MuxSession> {
|
||||
const { sessionId, workingDir, mode, name, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options;
|
||||
const {
|
||||
sessionId,
|
||||
workingDir,
|
||||
mode,
|
||||
name,
|
||||
niceConfig,
|
||||
model,
|
||||
claudeMode,
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
resumeSessionId,
|
||||
} = options;
|
||||
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
||||
|
||||
if (!isValidMuxName(muxName)) {
|
||||
@@ -433,6 +450,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
claudeMode,
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
resumeSessionId,
|
||||
});
|
||||
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
@@ -605,7 +623,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* preserving the session and its scrollback buffer.
|
||||
*/
|
||||
async respawnPane(options: RespawnPaneOptions): Promise<number | null> {
|
||||
const { sessionId, workingDir, mode, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options;
|
||||
const {
|
||||
sessionId,
|
||||
workingDir,
|
||||
mode,
|
||||
niceConfig,
|
||||
model,
|
||||
claudeMode,
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
resumeSessionId,
|
||||
} = options;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
const muxName = session.muxName;
|
||||
@@ -641,6 +669,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
claudeMode,
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
resumeSessionId,
|
||||
});
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
const cmd = wrapWithNice(baseCmd, config);
|
||||
|
||||
@@ -143,6 +143,8 @@ export interface SessionState {
|
||||
cliLatestVersion?: string;
|
||||
/** OpenCode-specific configuration (only for mode === 'opencode') */
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,4 +11,5 @@ export interface EventPort {
|
||||
batchTerminalData(sessionId: string, data: string): void;
|
||||
broadcastSessionStateDebounced(sessionId: string): void;
|
||||
batchTaskUpdate(sessionId: string, task: BackgroundTask): void;
|
||||
getSseClientCount(): number;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
* @fileoverview Shared constants, utility functions, and SSE event type registry for all frontend modules.
|
||||
*
|
||||
* This is the first script loaded in index.html. Every other frontend module depends on the
|
||||
* globals defined here: timing constants, Z-index layers, DEC 2026 sync markers, respawn
|
||||
* preset definitions, the SSE_EVENTS registry, and shared utilities (escapeHtml, extractSyncSegments,
|
||||
* globals defined here: timing constants, Z-index layers, respawn
|
||||
* preset definitions, the SSE_EVENTS registry, and shared utilities (escapeHtml,
|
||||
* getEventCoords, scheduleBackground, urlBase64ToUint8Array).
|
||||
*
|
||||
* @globals {function} urlBase64ToUint8Array - VAPID key conversion for Web Push
|
||||
* @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority)
|
||||
* @globals {function} extractSyncSegments - DEC 2026 terminal sync marker parser
|
||||
* @globals {function} getEventCoords - Unified mouse/touch coordinate extractor
|
||||
* @globals {function} escapeHtml - XSS-safe HTML escaping
|
||||
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (~73 event types)
|
||||
@@ -42,7 +41,7 @@ function urlBase64ToUint8Array(base64String) {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Default terminal scrollback (can be changed via settings)
|
||||
const DEFAULT_SCROLLBACK = 5000;
|
||||
const DEFAULT_SCROLLBACK = 20000;
|
||||
|
||||
// Timing constants
|
||||
const STUCK_THRESHOLD_DEFAULT_MS = 600000; // 10 minutes - default for stuck detection
|
||||
@@ -79,14 +78,8 @@ function scheduleBackground(fn) {
|
||||
else { requestAnimationFrame(fn); }
|
||||
}
|
||||
|
||||
// DEC mode 2026 - Synchronized Output
|
||||
// Wrap terminal writes with these markers to prevent partial-frame flicker.
|
||||
// Terminal buffers all output between markers and renders atomically.
|
||||
// Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
|
||||
// xterm.js doesn't support DEC 2026 natively, so we implement buffering ourselves.
|
||||
const DEC_SYNC_START = '\x1b[?2026h';
|
||||
const DEC_SYNC_END = '\x1b[?2026l';
|
||||
// Pre-compiled regex for stripping DEC 2026 markers (single pass instead of two replaceAll calls)
|
||||
// DEC mode 2026 marker stripping — xterm.js 6.0 handles sync natively,
|
||||
// but server-sent terminal buffers may still contain markers from Claude CLI.
|
||||
const DEC_SYNC_STRIP_RE = /\x1b\[\?2026[hl]/g;
|
||||
|
||||
// Built-in respawn configuration presets
|
||||
@@ -303,59 +296,6 @@ function getEventCoords(e) {
|
||||
return { clientX: e.clientX, clientY: e.clientY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process data containing DEC 2026 sync markers.
|
||||
* Strips markers and returns segments that should be written atomically.
|
||||
* Each returned segment represents content between SYNC_START and SYNC_END.
|
||||
* Content outside sync blocks is returned as-is.
|
||||
*
|
||||
* @param {string} data - Raw terminal data with potential sync markers
|
||||
* @returns {string[]} - Array of content segments to write (markers stripped)
|
||||
*/
|
||||
function extractSyncSegments(data) {
|
||||
const segments = [];
|
||||
let remaining = data;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const startIdx = remaining.indexOf(DEC_SYNC_START);
|
||||
|
||||
if (startIdx === -1) {
|
||||
// No more sync blocks, return rest as-is
|
||||
if (remaining.length > 0) {
|
||||
segments.push(remaining);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Content before sync block (if any)
|
||||
if (startIdx > 0) {
|
||||
segments.push(remaining.slice(0, startIdx));
|
||||
}
|
||||
|
||||
// Find matching end marker
|
||||
const afterStart = remaining.slice(startIdx + DEC_SYNC_START.length);
|
||||
const endIdx = afterStart.indexOf(DEC_SYNC_END);
|
||||
|
||||
if (endIdx === -1) {
|
||||
// No end marker found - sync block continues in next chunk
|
||||
// Include the start marker so it can be handled when more data arrives
|
||||
segments.push(remaining.slice(startIdx));
|
||||
break;
|
||||
}
|
||||
|
||||
// Extract synchronized content (without markers)
|
||||
const syncContent = afterStart.slice(0, endIdx);
|
||||
if (syncContent.length > 0) {
|
||||
segments.push(syncContent);
|
||||
}
|
||||
|
||||
// Continue with content after end marker
|
||||
remaining = afterStart.slice(endIdx + DEC_SYNC_END.length);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// HTML escape utility (shared by NotificationManager, CodemanApp, and ralph-wizard.js)
|
||||
const _htmlEscapeMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
const _htmlEscapePattern = /[&<>"']/g;
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="description" content="Claude Code session manager with web interface">
|
||||
<meta name="theme-color" content="#0a0a0a">
|
||||
<meta name="google" content="notranslate">
|
||||
<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">
|
||||
<link rel="stylesheet" href="styles.css?v=0.1631">
|
||||
<link rel="stylesheet" href="mobile.css?v=0.1631" media="(max-width: 1023px)">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="stylesheet" href="mobile.css" media="(max-width: 1023px)">
|
||||
<!-- xterm.css loaded async — terminal won't display until xterm.js runs anyway -->
|
||||
<link rel="preload" href="vendor/xterm.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
||||
<noscript><link rel="stylesheet" href="vendor/xterm.css"></noscript>
|
||||
@@ -20,12 +20,12 @@
|
||||
<script defer src="vendor/xterm-addon-fit.min.js"></script>
|
||||
<script defer src="vendor/xterm-addon-webgl.min.js"></script>
|
||||
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
||||
<script defer src="vendor/xterm-zerolag-input.js?v=0.3.2"></script>
|
||||
<script defer src="vendor/xterm-zerolag-input.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) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;background:#0a0a0a}
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#0a0a0a}
|
||||
.skeleton-header{height:40px;background:#111;border-bottom:1px solid #1a1a2e;display:flex;align-items:center;padding:0 12px}
|
||||
.skeleton-brand{color:#60a5fa;font-size:14px;font-weight:600;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.7}
|
||||
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
|
||||
@@ -59,6 +59,9 @@
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
||||
<span class="tunnel-dot"></span>
|
||||
</button>
|
||||
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
|
||||
<span class="connection-dot" id="connectionDot"></span>
|
||||
<span class="connection-text" id="connectionText"></span>
|
||||
@@ -228,7 +231,12 @@
|
||||
|
||||
<!-- Main Terminal Area -->
|
||||
<main class="main">
|
||||
<div class="terminal-container" id="terminalContainer"></div>
|
||||
<div class="terminal-wrap">
|
||||
<div class="terminal-container" id="terminalContainer"></div>
|
||||
<textarea id="cjkInput" rows="1" placeholder="CJK input (Enter = send, Esc = clear)"
|
||||
maxlength="65536" aria-label="CJK IME input field"
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Welcome Overlay (shown when no session active) -->
|
||||
<div class="welcome-overlay" id="welcomeOverlay">
|
||||
@@ -253,6 +261,10 @@
|
||||
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
||||
<div class="welcome-qr-url" id="welcomeQrUrl"></div>
|
||||
</div>
|
||||
<div class="history-sessions" id="historySessions" style="display:none">
|
||||
<h3 class="history-title">Resume Conversation</h3>
|
||||
<div class="history-list" id="historyList"></div>
|
||||
</div>
|
||||
<p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start</p>
|
||||
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop →</button>
|
||||
</div>
|
||||
@@ -323,6 +335,9 @@
|
||||
<button class="run-mode-option" data-mode="opencode" onclick="app.setRunMode('opencode')">
|
||||
<span class="run-mode-dot opencode"></span>OpenCode
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<div class="run-mode-header">Recent Sessions</div>
|
||||
<div class="run-mode-history" id="runModeHistory"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-count-group" title="Instance count">
|
||||
@@ -1674,14 +1689,15 @@
|
||||
<!-- Lines drawn dynamically -->
|
||||
</svg>
|
||||
|
||||
<script defer src="constants.js?v=0.3.2"></script>
|
||||
<script defer src="mobile-handlers.js?v=0.3.2"></script>
|
||||
<script defer src="voice-input.js?v=0.3.2"></script>
|
||||
<script defer src="notification-manager.js?v=0.3.2"></script>
|
||||
<script defer src="keyboard-accessory.js?v=0.3.2"></script>
|
||||
<script defer src="app.js?v=0.3.2"></script>
|
||||
<script defer src="ralph-wizard.js?v=0.3.2"></script>
|
||||
<script defer src="api-client.js?v=0.3.2"></script>
|
||||
<script defer src="subagent-windows.js?v=0.3.2"></script>
|
||||
<script defer src="constants.js"></script>
|
||||
<script defer src="mobile-handlers.js"></script>
|
||||
<script defer src="voice-input.js"></script>
|
||||
<script defer src="notification-manager.js"></script>
|
||||
<script defer src="keyboard-accessory.js"></script>
|
||||
<script defer src="input-cjk.js"></script>
|
||||
<script defer src="app.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
<script defer src="subagent-windows.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @fileoverview CJK IME input for xterm.js terminal.
|
||||
*
|
||||
* Always-visible textarea below the terminal (in index.html).
|
||||
* The browser handles IME composition natively — we just read
|
||||
* textarea.value on Enter and send it to PTY.
|
||||
* While this textarea has focus, window.cjkActive = true blocks xterm's onData.
|
||||
* Arrow keys and function keys are forwarded to PTY directly.
|
||||
*
|
||||
* @dependency index.html (#cjkInput textarea)
|
||||
* @globals {object} CjkInput — window.cjkActive (boolean) signals app.js to block xterm onData
|
||||
* @loadorder 5.5 of 10 — loaded after keyboard-accessory.js, before app.js
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const CjkInput = (() => {
|
||||
let _textarea = null;
|
||||
let _send = null;
|
||||
let _initialized = false;
|
||||
let _onMousedown = null;
|
||||
let _onFocus = null;
|
||||
let _onBlur = null;
|
||||
let _onKeydown = null;
|
||||
|
||||
const PASSTHROUGH_KEYS = {
|
||||
ArrowUp: '\x1b[A',
|
||||
ArrowDown: '\x1b[B',
|
||||
ArrowLeft: '\x1b[D',
|
||||
ArrowRight: '\x1b[C',
|
||||
Home: '\x1b[H',
|
||||
End: '\x1b[F',
|
||||
Tab: '\t',
|
||||
};
|
||||
|
||||
const CTRL_KEYS = {
|
||||
c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05',
|
||||
};
|
||||
|
||||
return {
|
||||
init({ send }) {
|
||||
// Guard against double-init: remove previous listeners
|
||||
if (_initialized) this.destroy();
|
||||
|
||||
_send = send;
|
||||
_textarea = document.getElementById('cjkInput');
|
||||
if (!_textarea) return this;
|
||||
|
||||
_onMousedown = (e) => { e.stopPropagation(); };
|
||||
_onFocus = () => { window.cjkActive = true; };
|
||||
_onBlur = () => { window.cjkActive = false; };
|
||||
_textarea.addEventListener('mousedown', _onMousedown);
|
||||
_textarea.addEventListener('focus', _onFocus);
|
||||
_textarea.addEventListener('blur', _onBlur);
|
||||
|
||||
_onKeydown = (e) => {
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
|
||||
// Enter: send accumulated text (or bare Enter if empty)
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (_textarea.value) {
|
||||
_send(_textarea.value + '\r');
|
||||
_textarea.value = '';
|
||||
} else {
|
||||
_send('\r');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: clear textarea
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
_textarea.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl combos: forward to PTY
|
||||
if (e.ctrlKey && CTRL_KEYS[e.key]) {
|
||||
e.preventDefault();
|
||||
_send(CTRL_KEYS[e.key]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace: delete from textarea if has text, else forward to PTY
|
||||
if (e.key === 'Backspace' && !_textarea.value) {
|
||||
e.preventDefault();
|
||||
_send('\x7f');
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow/function keys: forward to PTY when textarea is empty
|
||||
if (PASSTHROUGH_KEYS[e.key] && !_textarea.value) {
|
||||
e.preventDefault();
|
||||
_send(PASSTHROUGH_KEYS[e.key]);
|
||||
return;
|
||||
}
|
||||
};
|
||||
_textarea.addEventListener('keydown', _onKeydown);
|
||||
|
||||
_initialized = true;
|
||||
return this;
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (_textarea) {
|
||||
if (_onMousedown) _textarea.removeEventListener('mousedown', _onMousedown);
|
||||
if (_onFocus) _textarea.removeEventListener('focus', _onFocus);
|
||||
if (_onBlur) _textarea.removeEventListener('blur', _onBlur);
|
||||
if (_onKeydown) _textarea.removeEventListener('keydown', _onKeydown);
|
||||
}
|
||||
window.cjkActive = false;
|
||||
_onMousedown = _onFocus = _onBlur = _onKeydown = null;
|
||||
_initialized = false;
|
||||
},
|
||||
|
||||
get element() { return _textarea; },
|
||||
};
|
||||
})();
|
||||
@@ -98,14 +98,33 @@ const MobileDetection = {
|
||||
}
|
||||
},
|
||||
|
||||
/** Set --app-height CSS variable from visual viewport.
|
||||
* On iPad Safari with tabs, 100vh extends behind the tab bar.
|
||||
* visualViewport.height reflects the actual visible area. */
|
||||
updateAppHeight() {
|
||||
const vh = window.visualViewport?.height || window.innerHeight;
|
||||
document.documentElement.style.setProperty('--app-height', `${vh}px`);
|
||||
},
|
||||
|
||||
/** Initialize mobile detection and set up resize listener */
|
||||
init() {
|
||||
this.updateBodyClass();
|
||||
this.updateAppHeight();
|
||||
|
||||
// Update --app-height on viewport resize (orientation, tab bar toggle)
|
||||
if (window.visualViewport) {
|
||||
this._appHeightHandler = () => this.updateAppHeight();
|
||||
window.visualViewport.addEventListener('resize', this._appHeightHandler);
|
||||
}
|
||||
|
||||
// Debounced resize handler
|
||||
let resizeTimeout;
|
||||
this._resizeHandler = () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => this.updateBodyClass(), 100);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
this.updateBodyClass();
|
||||
this.updateAppHeight();
|
||||
}, 100);
|
||||
};
|
||||
window.addEventListener('resize', this._resizeHandler);
|
||||
|
||||
|
||||
@@ -234,6 +234,35 @@ html.mobile-init .file-browser-panel {
|
||||
.subagent-window-body {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
/* Toolbar — use desktop-style sizing on tablet (plenty of room at 430-768px) */
|
||||
.toolbar {
|
||||
padding: 0 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Instance count controls are hidden on tablet, so toolbar-group needs gap
|
||||
to space out Run / Stop / Run Shell (desktop uses gap:0 because -1+ separates them) */
|
||||
.toolbar-group {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-toolbar {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
min-height: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
@@ -498,6 +527,13 @@ html.mobile-init .file-browser-panel {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* iOS Safari with tab bar: position: fixed uses the layout viewport which
|
||||
extends behind the browser chrome. Offset the toolbar upward by the delta
|
||||
between 100vh (layout) and --app-height (visual). */
|
||||
.ios-device.safari-browser .toolbar {
|
||||
bottom: calc(var(--safe-area-bottom) + (100vh - var(--app-height, 100vh)));
|
||||
}
|
||||
|
||||
/* Show case selector in center */
|
||||
.toolbar-center {
|
||||
display: flex !important;
|
||||
@@ -626,12 +662,20 @@ html.mobile-init .file-browser-panel {
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
margin-bottom: 6px;
|
||||
min-width: 140px;
|
||||
min-width: 160px;
|
||||
max-width: 80vw;
|
||||
}
|
||||
|
||||
.run-mode-option {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.run-mode-history {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* Stop button - visible on mobile, icon-only */
|
||||
@@ -1895,6 +1939,87 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================================
|
||||
Keyboard Accessory Bar — all mobile/tablet sizes
|
||||
Visual styles extracted from phone breakpoint so they apply on iPad too.
|
||||
Phone-specific positioning (position: fixed) remains in @media (max-width: 430px).
|
||||
============================================================================ */
|
||||
.keyboard-accessory-bar {
|
||||
display: none;
|
||||
height: 44px;
|
||||
background: #1a1a1a;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 6px 8px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
.keyboard-accessory-bar.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.accessory-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
color: #e5e5e5;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.accessory-btn.confirming {
|
||||
background: #6b4f00;
|
||||
border-color: #b8860b;
|
||||
color: #ffd54f;
|
||||
}
|
||||
|
||||
.accessory-btn:active {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.accessory-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow {
|
||||
padding: 6px 10px;
|
||||
background: #1e3a5f;
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow:active {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss {
|
||||
padding: 8px 14px;
|
||||
background: #2a2a2a;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 6px;
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss:active {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
iOS Safari Specific Fixes
|
||||
============================================================================ */
|
||||
|
||||
@@ -116,6 +116,8 @@ body {
|
||||
background: var(--bg-dark);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
height: var(--app-height, 100dvh);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -124,6 +126,8 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
height: var(--app-height, 100dvh);
|
||||
}
|
||||
|
||||
/* Compact Header */
|
||||
@@ -266,6 +270,8 @@ body {
|
||||
will-change: opacity;
|
||||
}
|
||||
.session-tab .tab-status.error { background: var(--red); }
|
||||
.session-tab .tab-status.ended { background: var(--text-muted); opacity: 0.5; }
|
||||
.session-tab[data-ended] { opacity: 0.55; }
|
||||
|
||||
/* Session color coding - left border indicator */
|
||||
.session-tab[data-color="red"] { border-left: 3px solid var(--session-red); }
|
||||
@@ -444,6 +450,249 @@ body {
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* Tunnel Indicator */
|
||||
.tunnel-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tunnel-indicator:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.tunnel-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 6px #22c55e, 0 0 12px rgba(34, 197, 94, 0.4);
|
||||
animation: tunnel-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tunnel-indicator.connecting .tunnel-dot {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 6px #f59e0b, 0 0 12px rgba(245, 158, 11, 0.4);
|
||||
animation: tunnel-glow-amber 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes tunnel-glow {
|
||||
0%, 100% { box-shadow: 0 0 6px #22c55e, 0 0 12px rgba(34, 197, 94, 0.4); }
|
||||
50% { box-shadow: 0 0 8px #22c55e, 0 0 18px rgba(34, 197, 94, 0.6); }
|
||||
}
|
||||
|
||||
@keyframes tunnel-glow-amber {
|
||||
0%, 100% { box-shadow: 0 0 6px #f59e0b, 0 0 12px rgba(245, 158, 11, 0.4); opacity: 1; }
|
||||
50% { box-shadow: 0 0 4px #f59e0b; opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Tunnel Panel Dropdown */
|
||||
.tunnel-panel {
|
||||
position: fixed;
|
||||
top: 36px;
|
||||
right: 12px;
|
||||
width: 320px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tunnel-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tunnel-panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tunnel-panel-status {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.tunnel-panel-status.offline {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tunnel-panel-body {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.tunnel-panel-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tunnel-panel-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tunnel-panel-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tunnel-panel-url {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--accent-hover);
|
||||
word-break: break-all;
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.tunnel-panel-url:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.tunnel-panel-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tunnel-panel-stat-value {
|
||||
color: var(--text);
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tunnel-panel-session {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tunnel-panel-session-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tunnel-panel-session-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tunnel-panel-session-method {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tunnel-panel-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tunnel-panel-btn {
|
||||
flex: 1;
|
||||
padding: 6px 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-start {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-start:hover {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-stop {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-stop:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-qr {
|
||||
background: var(--bg-input);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-qr:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-revoke {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-revoke:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.tunnel-panel-empty {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.header-tokens {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
@@ -1633,6 +1882,13 @@ body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.terminal-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
background: #0d0d0d;
|
||||
@@ -1837,6 +2093,70 @@ body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* History sessions panel (welcome overlay) */
|
||||
.history-sessions {
|
||||
margin-top: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.history-item-dir {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-item-meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-item-size {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
min-width: 45px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.welcome-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
@@ -2017,7 +2337,8 @@ body {
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
z-index: 1000;
|
||||
min-width: 150px;
|
||||
min-width: 180px;
|
||||
max-width: 300px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
}
|
||||
.run-mode-menu.active {
|
||||
@@ -2054,6 +2375,40 @@ body {
|
||||
.run-mode-dot.claude { background: #3b82f6; }
|
||||
.run-mode-dot.opencode { background: #10b981; }
|
||||
|
||||
.run-mode-sep {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.run-mode-header {
|
||||
padding: 4px 10px 2px;
|
||||
font-size: 0.72em;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.run-mode-history {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.run-mode-option .hist-dir {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.run-mode-option .hist-meta {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.run-mode-hist-empty {
|
||||
padding: 6px 10px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Shell Button - Special styling */
|
||||
.btn-toolbar.btn-shell {
|
||||
background: linear-gradient(135deg, #1a2e1a 0%, #234023 100%);
|
||||
@@ -4204,6 +4559,7 @@ kbd {
|
||||
right: 0;
|
||||
width: 340px;
|
||||
height: calc(100vh - var(--header-height));
|
||||
height: calc(100dvh - var(--header-height));
|
||||
background: var(--bg-card);
|
||||
border-left: 1px solid var(--border);
|
||||
z-index: 10001;
|
||||
@@ -5572,6 +5928,7 @@ kbd {
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
pointer-events: none;
|
||||
z-index: 999; /* Below windows (1000+), above panels (91) */
|
||||
}
|
||||
@@ -5799,6 +6156,7 @@ kbd {
|
||||
right: 20px;
|
||||
width: 280px;
|
||||
height: calc(100vh - var(--header-height) - var(--toolbar-height) - 40px);
|
||||
height: calc(100dvh - var(--header-height) - var(--toolbar-height) - 40px);
|
||||
max-height: 600px;
|
||||
min-width: 200px;
|
||||
min-height: 300px;
|
||||
@@ -7151,3 +7509,34 @@ kbd {
|
||||
.advanced-options-content {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
CJK IME Input
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
#cjkInput {
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, monospace;
|
||||
font-size: 14px;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #333;
|
||||
border-top: none;
|
||||
padding: 6px 10px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
line-height: 1.4;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#cjkInput:focus {
|
||||
border-color: #339af0;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
#cjkInput::placeholder {
|
||||
color: #495057;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* that replaces ~43 inline not-found checks across route handlers.
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { Session } from '../session.js';
|
||||
import { ApiErrorCode, createErrorResponse } from '../types.js';
|
||||
@@ -18,6 +18,20 @@ import type { EventPort } from './ports/event-port.js';
|
||||
export const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||
export const SETTINGS_PATH = join(homedir(), '.codeman', 'settings.json');
|
||||
|
||||
/**
|
||||
* Validates that a path component doesn't escape the base directory.
|
||||
* Returns the resolved full path, or null if the path is a traversal attempt.
|
||||
*/
|
||||
export function validatePathWithinBase(name: string, baseDir: string): string | null {
|
||||
const fullPath = resolve(join(baseDir, name));
|
||||
const resolvedBase = resolve(baseDir);
|
||||
const relPath = relative(resolvedBase, fullPath);
|
||||
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
||||
return null;
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
// Maximum hook data size (prevents oversized SSE broadcasts)
|
||||
const MAX_HOOK_DATA_SIZE = 8 * 1024;
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
||||
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 { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { CASES_DIR } from '../route-helpers.js';
|
||||
import { CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
|
||||
@@ -74,13 +74,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
const { name, description } = result.data;
|
||||
|
||||
const casePath = join(CASES_DIR, name);
|
||||
|
||||
// Security: Path traversal protection - use relative path check
|
||||
const resolvedPath = resolve(casePath);
|
||||
const resolvedBase = resolve(CASES_DIR);
|
||||
const relPath = relative(resolvedBase, resolvedPath);
|
||||
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
||||
const casePath = validatePathWithinBase(name, CASES_DIR);
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
}
|
||||
|
||||
@@ -167,11 +162,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
app.get('/api/cases/:name', async (req) => {
|
||||
const { name } = req.params as { name: string };
|
||||
|
||||
// Security: Path traversal protection
|
||||
const resolvedPath = resolve(join(CASES_DIR, name));
|
||||
const resolvedBase = resolve(CASES_DIR);
|
||||
const relPath = relative(resolvedBase, resolvedPath);
|
||||
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
||||
if (!validatePathWithinBase(name, CASES_DIR)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
@@ -210,11 +201,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
app.get('/api/cases/:name/fix-plan', async (req) => {
|
||||
const { name } = req.params as { name: string };
|
||||
|
||||
// Security: Path traversal protection
|
||||
const resolvedPath = resolve(join(CASES_DIR, name));
|
||||
const resolvedBase = resolve(CASES_DIR);
|
||||
const relPath = relative(resolvedBase, resolvedPath);
|
||||
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
||||
if (!validatePathWithinBase(name, CASES_DIR)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
@@ -334,13 +321,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
|
||||
app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
|
||||
const { caseName } = req.params as { caseName: string };
|
||||
let casePath = join(CASES_DIR, caseName);
|
||||
|
||||
// Security: Path traversal protection - use relative path check
|
||||
const resolvedCase = resolve(casePath);
|
||||
const resolvedBase = resolve(CASES_DIR);
|
||||
const relPath = relative(resolvedBase, resolvedCase);
|
||||
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
||||
let casePath = validatePathWithinBase(caseName, CASES_DIR);
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
@@ -394,21 +376,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
// Cache disabled to ensure fresh prompts when starting new plan generations
|
||||
app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
|
||||
const { caseName, filePath } = req.params as { caseName: string; filePath: string };
|
||||
let casePath = join(CASES_DIR, caseName);
|
||||
let casePath = validatePathWithinBase(caseName, CASES_DIR);
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
// Prevent browser caching - prompts change between plan generations
|
||||
reply.header('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
reply.header('Pragma', 'no-cache');
|
||||
reply.header('Expires', '0');
|
||||
|
||||
// Security: Path traversal protection for case name - use relative path check
|
||||
const resolvedCase = resolve(casePath);
|
||||
const resolvedBase = resolve(CASES_DIR);
|
||||
const relPath = relative(resolvedBase, resolvedCase);
|
||||
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
// Check linked cases if path doesn't exist
|
||||
if (!existsSync(casePath)) {
|
||||
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
||||
|
||||
@@ -14,3 +14,4 @@ export { registerSessionRoutes } from './session-routes.js';
|
||||
export { registerRespawnRoutes } from './respawn-routes.js';
|
||||
export { registerRalphRoutes } from './ralph-routes.js';
|
||||
export { registerPlanRoutes } from './plan-routes.js';
|
||||
export { registerWsRoutes } from './ws-routes.js';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { Session } from '../../session.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
PlanTaskUpdateSchema,
|
||||
PlanTaskAddSchema,
|
||||
} from '../schemas.js';
|
||||
import { findSessionOrFail, CASES_DIR } from '../route-helpers.js';
|
||||
import { findSessionOrFail, CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
@@ -232,12 +232,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// Determine output directory for saving wizard results
|
||||
let outputDir: string | undefined;
|
||||
if (caseName) {
|
||||
const casePath = join(CASES_DIR, caseName);
|
||||
// Security: Path traversal protection - use relative path check
|
||||
const resolvedCase = resolve(casePath);
|
||||
const resolvedBase = resolve(CASES_DIR);
|
||||
const relPath = relative(resolvedBase, resolvedCase);
|
||||
if (!relPath.startsWith('..') && !isAbsolute(relPath) && existsSync(casePath)) {
|
||||
const casePath = validatePathWithinBase(caseName, CASES_DIR);
|
||||
if (casePath && existsSync(casePath)) {
|
||||
outputDir = join(casePath, 'ralph-wizard');
|
||||
|
||||
// Clear old ralph-wizard directory to ensure fresh prompts for each generation
|
||||
|
||||
@@ -505,7 +505,7 @@ export function registerRalphRoutes(
|
||||
settings.lastUsedCase = caseName;
|
||||
const dir = dirname(SETTINGS_PATH);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {});
|
||||
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ const noopEventPort: EventPort = {
|
||||
batchTerminalData: () => {},
|
||||
broadcastSessionStateDebounced: () => {},
|
||||
batchTaskUpdate: () => {},
|
||||
getSseClientCount: () => 0,
|
||||
};
|
||||
|
||||
export function registerRespawnRoutes(
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import {
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
QuickRunSchema,
|
||||
QuickStartSchema,
|
||||
} from '../schemas.js';
|
||||
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, validatePathWithinBase } from '../route-helpers.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
@@ -144,6 +144,7 @@ export function registerSessionRoutes(
|
||||
claudeMode: claudeModeConfig.claudeMode,
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||
resumeSessionId: body.resumeSessionId,
|
||||
});
|
||||
|
||||
ctx.addSession(session);
|
||||
@@ -788,13 +789,8 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const casePath = join(CASES_DIR, caseName);
|
||||
|
||||
// Security: Path traversal protection - use relative path check
|
||||
const resolvedPath = resolve(casePath);
|
||||
const resolvedBase = resolve(CASES_DIR);
|
||||
const relPath = relative(resolvedBase, resolvedPath);
|
||||
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
||||
const casePath = validatePathWithinBase(caseName, CASES_DIR);
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
}
|
||||
|
||||
@@ -922,4 +918,91 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// History — list past Claude conversations for resume
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.get('/api/history/sessions', async () => {
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const results: Array<{
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
projectKey: string;
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const projPath = join(projectsDir, projDir);
|
||||
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';
|
||||
|
||||
const entries = await fs.readdir(projPath);
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.jsonl')) continue;
|
||||
const sessionId = entry.replace('.jsonl', '');
|
||||
// Only valid UUIDs
|
||||
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId)) continue;
|
||||
|
||||
const filePath = join(projPath, entry);
|
||||
const fileStat = await fs.stat(filePath).catch(() => null);
|
||||
if (!fileStat) continue;
|
||||
// Skip files too small to contain real conversation (metadata-only sessions
|
||||
// like file-history-snapshot entries are typically < 4KB)
|
||||
if (fileStat.size < 4000) continue;
|
||||
|
||||
// Quick content check: verify actual conversation data exists.
|
||||
// Sessions with only file-history-snapshot or hook_progress entries have
|
||||
// no "user"/"assistant" messages and will fail claude --resume.
|
||||
// Files > 50KB are almost certainly real conversations (skip the read).
|
||||
if (fileStat.size < 50000) {
|
||||
try {
|
||||
const fd = await fs.open(filePath, 'r');
|
||||
const buf = Buffer.alloc(16384);
|
||||
const { bytesRead } = await fd.read(buf, 0, 16384, 0);
|
||||
await fd.close();
|
||||
const head = buf.toString('utf8', 0, bytesRead);
|
||||
if (
|
||||
!head.includes('"type":"user"') &&
|
||||
!head.includes('"type":"assistant"') &&
|
||||
!head.includes('"type":"summary"')
|
||||
) {
|
||||
continue; // No conversation content — skip
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
sessionId,
|
||||
workingDir,
|
||||
projectKey: projDir,
|
||||
sizeBytes: fileStat.size,
|
||||
lastModified: fileStat.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Projects dir may not exist
|
||||
}
|
||||
|
||||
// Sort by lastModified descending
|
||||
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
|
||||
return { sessions: results.slice(0, 50) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,6 +104,23 @@ export function registerSystemRoutes(
|
||||
|
||||
app.get('/api/tunnel/status', async () => ctx.tunnelManager.getStatus());
|
||||
|
||||
app.get('/api/tunnel/info', async () => {
|
||||
const status = ctx.tunnelManager.getStatus();
|
||||
const sseClients = ctx.getSseClientCount();
|
||||
const sessions: Array<{ ip: string; ua: string; createdAt: number; method: string }> = [];
|
||||
if (ctx.authSessions) {
|
||||
for (const [, record] of ctx.authSessions) {
|
||||
sessions.push({ ip: record.ip, ua: record.ua, createdAt: record.createdAt, method: record.method });
|
||||
}
|
||||
}
|
||||
return {
|
||||
...status,
|
||||
sseClients,
|
||||
authEnabled: !!process.env.CODEMAN_PASSWORD,
|
||||
authSessions: sessions,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/tunnel/qr', async (_req, reply) => {
|
||||
const url = ctx.tunnelManager.getUrl();
|
||||
if (!url) {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* @fileoverview WebSocket terminal I/O route.
|
||||
*
|
||||
* Provides a low-latency bidirectional channel for terminal input/output,
|
||||
* bypassing the HTTP POST + SSE path that adds per-request middleware overhead.
|
||||
* Auth is checked once on the WebSocket upgrade handshake (cookies are included
|
||||
* automatically by the browser). After upgrade, the connection is raw — no
|
||||
* per-message middleware processing.
|
||||
*
|
||||
* Additive: the existing HTTP POST /api/sessions/:id/input and SSE session:terminal
|
||||
* paths remain fully functional. The frontend opts into WS when available and
|
||||
* falls back transparently.
|
||||
*
|
||||
* Terminal output is micro-batched at 8ms to group Ink's rapid cursor-up redraws
|
||||
* into single frames, preventing flicker from split ANSI sequences. This matches
|
||||
* the SSE path's server-side batching (16-50ms) but at a shorter interval since
|
||||
* WS has no Traefik buffering overhead.
|
||||
*
|
||||
* Protocol (all JSON text frames):
|
||||
* Server -> Client:
|
||||
* {"t":"o","d":"..."} — terminal output
|
||||
* {"t":"c"} — clear terminal
|
||||
* {"t":"r"} — needs refresh (reload buffer)
|
||||
* Client -> Server:
|
||||
* {"t":"i","d":"..."} — input (keystroke or paste)
|
||||
* {"t":"z","c":N,"r":N} — resize terminal
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import type { WebSocket } from 'ws';
|
||||
import type { SessionPort } from '../ports/session-port.js';
|
||||
import { MAX_INPUT_LENGTH } from '../../config/terminal-limits.js';
|
||||
|
||||
/** Micro-batch interval for terminal output (ms). Short enough for low latency,
|
||||
* long enough to group Ink's rapid cursor-up redraw sequences into single frames. */
|
||||
const WS_BATCH_INTERVAL_MS = 8;
|
||||
|
||||
/** Flush immediately when batch exceeds this size (bytes) for responsiveness. */
|
||||
const WS_BATCH_FLUSH_THRESHOLD = 16384;
|
||||
|
||||
/** How often to ping each WebSocket client (ms). Detects stale connections that
|
||||
* TCP keepalive won't catch for minutes, especially through tunnels/proxies. */
|
||||
const WS_PING_INTERVAL_MS = 30_000;
|
||||
|
||||
/** If pong isn't received within this window after a ping, terminate the socket. */
|
||||
const WS_PONG_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** DEC 2026 synchronized update markers. Wrapping output in these tells xterm.js
|
||||
* to buffer all content and render atomically in a single frame — eliminates
|
||||
* flicker from cursor-up redraws that Ink sends without its own sync markers
|
||||
* (DA capability negotiation fails through the PTY→server→WS proxy chain). */
|
||||
const DEC_2026_START = '\x1b[?2026h';
|
||||
const DEC_2026_END = '\x1b[?2026l';
|
||||
|
||||
/** Max concurrent WS connections per session. Prevents listener/bandwidth multiplication. */
|
||||
const MAX_WS_PER_SESSION = 5;
|
||||
|
||||
/** Track active WS connections per session for connection limiting. */
|
||||
const sessionWsCount = new Map<string, number>();
|
||||
|
||||
export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void {
|
||||
app.get<{ Params: { id: string } }>('/ws/sessions/:id/terminal', { websocket: true }, (socket: WebSocket, req) => {
|
||||
const { id } = req.params;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
socket.close(4004, 'Session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Enforce per-session connection limit
|
||||
const currentCount = sessionWsCount.get(id) ?? 0;
|
||||
if (currentCount >= MAX_WS_PER_SESSION) {
|
||||
socket.close(4008, 'Too many connections');
|
||||
return;
|
||||
}
|
||||
sessionWsCount.set(id, currentCount + 1);
|
||||
|
||||
// Swallow socket errors — cleanup happens in 'close'
|
||||
socket.on('error', () => {});
|
||||
|
||||
// Per-connection micro-batch state
|
||||
let batchChunks: string[] = [];
|
||||
let batchSize = 0;
|
||||
let batchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const flushBatch = () => {
|
||||
batchTimer = null;
|
||||
if (batchChunks.length === 0 || socket.readyState !== 1) {
|
||||
batchChunks = [];
|
||||
batchSize = 0;
|
||||
return;
|
||||
}
|
||||
const data = batchChunks.join('');
|
||||
batchChunks = [];
|
||||
batchSize = 0;
|
||||
socket.send(`{"t":"o","d":${JSON.stringify(DEC_2026_START + data + DEC_2026_END)}}`);
|
||||
};
|
||||
|
||||
// Attach message handler synchronously BEFORE any async work
|
||||
// (@fastify/websocket requirement to avoid dropped messages).
|
||||
socket.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(String(raw));
|
||||
if (msg.t === 'i' && typeof msg.d === 'string') {
|
||||
if (msg.d.length > MAX_INPUT_LENGTH) return;
|
||||
session.write(msg.d);
|
||||
} else if (
|
||||
msg.t === 'z' &&
|
||||
Number.isInteger(msg.c) &&
|
||||
Number.isInteger(msg.r) &&
|
||||
msg.c >= 1 &&
|
||||
msg.c <= 500 &&
|
||||
msg.r >= 1 &&
|
||||
msg.r <= 200
|
||||
) {
|
||||
session.resize(msg.c, msg.r);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
});
|
||||
|
||||
// Terminal output -> micro-batched WS send
|
||||
const onTerminal = (data: string) => {
|
||||
if (socket.readyState !== 1) return;
|
||||
batchChunks.push(data);
|
||||
batchSize += data.length;
|
||||
|
||||
// Flush immediately for large batches (responsiveness during bulk output)
|
||||
if (batchSize > WS_BATCH_FLUSH_THRESHOLD) {
|
||||
if (batchTimer) {
|
||||
clearTimeout(batchTimer);
|
||||
}
|
||||
flushBatch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start timer if not already running
|
||||
if (!batchTimer) {
|
||||
batchTimer = setTimeout(flushBatch, WS_BATCH_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const onClearTerminal = () => {
|
||||
if (socket.readyState === 1) {
|
||||
socket.send('{"t":"c"}');
|
||||
}
|
||||
};
|
||||
|
||||
const onNeedsRefresh = () => {
|
||||
if (socket.readyState === 1) {
|
||||
socket.send('{"t":"r"}');
|
||||
}
|
||||
};
|
||||
|
||||
// Close WS when session exits (deleted, respawned, or crashed) — prevents
|
||||
// orphaned listeners and stale writes to a dead PTY.
|
||||
const onSessionExit = () => {
|
||||
socket.close(4009, 'Session terminated');
|
||||
};
|
||||
|
||||
session.on('terminal', onTerminal);
|
||||
session.on('clearTerminal', onClearTerminal);
|
||||
session.on('needsRefresh', onNeedsRefresh);
|
||||
session.on('exit', onSessionExit);
|
||||
|
||||
// Heartbeat: detect stale connections (especially through tunnels where
|
||||
// TCP RST can take minutes to propagate).
|
||||
let pongTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
socket.on('pong', () => {
|
||||
if (pongTimeout) {
|
||||
clearTimeout(pongTimeout);
|
||||
pongTimeout = null;
|
||||
}
|
||||
});
|
||||
|
||||
const pingInterval = setInterval(() => {
|
||||
if (socket.readyState !== 1) return;
|
||||
socket.ping();
|
||||
pongTimeout = setTimeout(() => {
|
||||
socket.terminate();
|
||||
}, WS_PONG_TIMEOUT_MS);
|
||||
}, WS_PING_INTERVAL_MS);
|
||||
|
||||
socket.on('close', () => {
|
||||
clearInterval(pingInterval);
|
||||
if (pongTimeout) clearTimeout(pongTimeout);
|
||||
if (batchTimer) clearTimeout(batchTimer);
|
||||
batchChunks = [];
|
||||
session.off('terminal', onTerminal);
|
||||
session.off('clearTerminal', onClearTerminal);
|
||||
session.off('needsRefresh', onNeedsRefresh);
|
||||
session.off('exit', onSessionExit);
|
||||
|
||||
// Decrement per-session connection count
|
||||
const count = sessionWsCount.get(id) ?? 1;
|
||||
if (count <= 1) {
|
||||
sessionWsCount.delete(id);
|
||||
} else {
|
||||
sessionWsCount.set(id, count - 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { SAFE_PATH_PATTERN } from '../utils/regex-patterns.js';
|
||||
import { SAFE_PATH_PATTERN } from '../utils/index.js';
|
||||
|
||||
// ========== Path Validation ==========
|
||||
|
||||
@@ -125,6 +125,12 @@ export const CreateSessionSchema = z.object({
|
||||
name: z.string().max(100).optional(),
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||
resumeSessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-f0-9-]+$/, 'resumeSessionId must be a valid UUID')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -315,6 +321,30 @@ export const SettingsUpdateSchema = z
|
||||
insertMode: z.string().max(20).optional(),
|
||||
})
|
||||
.optional(),
|
||||
// Run mode preference (cross-device sync)
|
||||
runMode: z.string().max(20).optional(),
|
||||
// Custom respawn presets (cross-device sync, replaces localStorage-only storage)
|
||||
respawnPresets: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().max(100),
|
||||
name: z.string().max(100),
|
||||
config: z.object({
|
||||
idleTimeoutMs: z.number().optional(),
|
||||
updatePrompt: z.string().max(5000).optional(),
|
||||
interStepDelayMs: z.number().optional(),
|
||||
sendClear: z.boolean().optional(),
|
||||
sendInit: z.boolean().optional(),
|
||||
kickstartPrompt: z.string().max(5000).optional(),
|
||||
autoAcceptPrompts: z.boolean().optional(),
|
||||
}),
|
||||
durationMinutes: z.number().optional(),
|
||||
builtIn: z.boolean().optional(),
|
||||
createdAt: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.max(20)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import Fastify, { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import fastifyCompress from '@fastify/compress';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync } from 'node:fs';
|
||||
@@ -103,6 +104,7 @@ import {
|
||||
registerRespawnRoutes,
|
||||
registerRalphRoutes,
|
||||
registerPlanRoutes,
|
||||
registerWsRoutes,
|
||||
} from './routes/index.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -123,13 +125,6 @@ import {
|
||||
INACTIVITY_TIMEOUT_MS,
|
||||
} from '../config/server-timing.js';
|
||||
|
||||
// DEC mode 2026 - Synchronized Output
|
||||
// When terminal supports this, it buffers all output between start/end markers
|
||||
// and renders atomically, eliminating partial-frame flicker from Ink redraws.
|
||||
// Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
|
||||
const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update
|
||||
const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update (flush to screen)
|
||||
|
||||
// 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.
|
||||
@@ -217,6 +212,8 @@ export class WebServer extends EventEmitter {
|
||||
* 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();
|
||||
private store = getStore();
|
||||
@@ -505,6 +502,7 @@ export class WebServer extends EventEmitter {
|
||||
batchTerminalData: this.batchTerminalData.bind(this),
|
||||
broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this),
|
||||
batchTaskUpdate: this.batchTaskUpdate.bind(this),
|
||||
getSseClientCount: () => this.remoteSseClients.size,
|
||||
// RespawnPort
|
||||
respawnControllers: this.respawnControllers,
|
||||
respawnTimers: this.respawnTimers,
|
||||
@@ -567,6 +565,9 @@ export class WebServer extends EventEmitter {
|
||||
this.qrAuthFailures = authState.qrAuthFailures;
|
||||
}
|
||||
|
||||
// WebSocket support (terminal I/O — low-latency bidirectional channel)
|
||||
await this.app.register(fastifyWebsocket);
|
||||
|
||||
// Security headers + CORS
|
||||
registerSecurityHeaders(this.app, this.https);
|
||||
// Service worker must never be cached — browsers check for SW updates on navigation
|
||||
@@ -578,14 +579,22 @@ export class WebServer extends EventEmitter {
|
||||
.sendFile('sw.js', join(__dirname, 'public'));
|
||||
});
|
||||
|
||||
// Serve static files — versioned assets (?v=X) are immutable, cache aggressively
|
||||
// Serve static files — content-hashed assets (e.g. app.a3f8c2e1.js) are immutable, cache aggressively.
|
||||
// HTML must revalidate every time so browsers pick up new hashed filenames after deploys.
|
||||
// cacheControl disabled so setHeaders has full control (fastify-static's reply.headers() overwrites setHeaders otherwise).
|
||||
// preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression
|
||||
await this.app.register(fastifyStatic, {
|
||||
root: join(__dirname, 'public'),
|
||||
prefix: '/',
|
||||
maxAge: '1y',
|
||||
immutable: true,
|
||||
cacheControl: false,
|
||||
preCompressed: true,
|
||||
setHeaders: (res, path) => {
|
||||
if (path.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
} else {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// SSE endpoint for real-time updates
|
||||
@@ -620,6 +629,12 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
this.sseClients.set(reply, sessionFilter);
|
||||
|
||||
// Track tunnel clients — cloudflared proxies locally so req.ip is always
|
||||
// 127.0.0.1; detect tunnel traffic via Cf-Connecting-Ip header instead.
|
||||
if (req.headers['cf-connecting-ip']) {
|
||||
this.remoteSseClients.add(reply);
|
||||
}
|
||||
|
||||
// Send initial state
|
||||
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
|
||||
// Buffers are fetched on-demand when switching tabs
|
||||
@@ -636,6 +651,7 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
req.raw.on('close', () => {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.delete(reply);
|
||||
this.backpressuredClients.delete(reply);
|
||||
});
|
||||
});
|
||||
@@ -689,6 +705,7 @@ export class WebServer extends EventEmitter {
|
||||
registerRespawnRoutes(this.app, ctx);
|
||||
registerRalphRoutes(this.app, ctx);
|
||||
registerPlanRoutes(this.app, ctx);
|
||||
registerWsRoutes(this.app, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1936,6 +1953,7 @@ export class WebServer extends EventEmitter {
|
||||
globalStats: this.store.getAggregateStats(activeSessionTokens),
|
||||
subagents: subagentWatcher.getRecentSubagents(15), // 15 min to avoid stale agents
|
||||
timestamp: now,
|
||||
inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON',
|
||||
};
|
||||
|
||||
this.cachedLightState = { data: result, timestamp: now };
|
||||
@@ -1947,6 +1965,7 @@ export class WebServer extends EventEmitter {
|
||||
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
} catch {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.delete(reply);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1976,6 +1995,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
} catch {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.delete(reply);
|
||||
this.backpressuredClients.delete(reply);
|
||||
}
|
||||
}
|
||||
@@ -2107,14 +2127,13 @@ export class WebServer extends EventEmitter {
|
||||
if (chunks && chunks.length > 0) {
|
||||
// Join chunks only at flush time (avoids O(n^2) string concatenation in batchTerminalData)
|
||||
const data = chunks.join('');
|
||||
// Wrap with DEC mode 2026 synchronized output markers
|
||||
// Terminal buffers all output between markers and renders atomically,
|
||||
// eliminating partial-frame flicker from Ink's full-screen redraws.
|
||||
// Unsupported terminals ignore these sequences harmlessly.
|
||||
const syncData = DEC_SYNC_START + data + DEC_SYNC_END;
|
||||
// xterm.js 6.0+ handles DEC 2026 synchronized output natively.
|
||||
// Claude CLI (Ink) already emits its own DEC 2026 markers around redraws.
|
||||
// Do NOT add an outer wrapper — DEC 2026 is not reference-counted, so
|
||||
// the inner 2026l would prematurely exit sync mode, defeating the purpose.
|
||||
// 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);
|
||||
const escapedData = JSON.stringify(data);
|
||||
// Append tunnel padding for immediate Cloudflare proxy flush —
|
||||
// terminal data is high-frequency and latency-sensitive.
|
||||
const padding = this._isTunnelActive ? SSE_PADDING : '';
|
||||
@@ -2316,6 +2335,7 @@ export class WebServer extends EventEmitter {
|
||||
// Remove dead clients
|
||||
for (const client of deadClients) {
|
||||
this.sseClients.delete(client);
|
||||
this.remoteSseClients.delete(client);
|
||||
this.backpressuredClients.delete(client);
|
||||
}
|
||||
|
||||
@@ -2704,6 +2724,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
this.sseClients.clear();
|
||||
this.remoteSseClients.clear();
|
||||
this.backpressuredClients.clear();
|
||||
|
||||
// Clear per-session batch timers
|
||||
|
||||
@@ -38,6 +38,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
||||
batchTerminalData: vi.fn(),
|
||||
broadcastSessionStateDebounced: vi.fn(),
|
||||
batchTaskUpdate: vi.fn(),
|
||||
getSseClientCount: vi.fn(() => 0),
|
||||
|
||||
// -- RespawnPort --
|
||||
respawnControllers: new Map(),
|
||||
|
||||
@@ -75,11 +75,7 @@ describe('session-routes', () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx.cleanupSession).toHaveBeenCalledWith(
|
||||
harness.ctx._sessionId,
|
||||
true,
|
||||
'user_delete',
|
||||
);
|
||||
expect(harness.ctx.cleanupSession).toHaveBeenCalledWith(harness.ctx._sessionId, true, 'user_delete');
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
@@ -123,10 +119,7 @@ describe('session-routes', () => {
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.name).toBe('new-name');
|
||||
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
|
||||
'session:updated',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:updated', expect.anything());
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
@@ -462,4 +455,109 @@ describe('session-routes', () => {
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/history/sessions ==========
|
||||
|
||||
describe('GET /api/history/sessions', () => {
|
||||
it('returns sessions array', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/history/sessions',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body).toHaveProperty('sessions');
|
||||
expect(Array.isArray(body.sessions)).toBe(true);
|
||||
});
|
||||
|
||||
it('sessions have required fields', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/history/sessions',
|
||||
});
|
||||
const body = JSON.parse(res.body);
|
||||
for (const session of body.sessions) {
|
||||
expect(session).toHaveProperty('sessionId');
|
||||
expect(session).toHaveProperty('workingDir');
|
||||
expect(session).toHaveProperty('projectKey');
|
||||
expect(session).toHaveProperty('sizeBytes');
|
||||
expect(session).toHaveProperty('lastModified');
|
||||
// sessionId must be a valid UUID
|
||||
expect(session.sessionId).toMatch(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('sessions are sorted by lastModified descending', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/history/sessions',
|
||||
});
|
||||
const body = JSON.parse(res.body);
|
||||
const dates = body.sessions.map((s: { lastModified: string }) => new Date(s.lastModified).getTime());
|
||||
for (let i = 1; i < dates.length; i++) {
|
||||
expect(dates[i - 1]).toBeGreaterThanOrEqual(dates[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns at most 50 sessions', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/history/sessions',
|
||||
});
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.sessions.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions (with resumeSessionId) ==========
|
||||
|
||||
describe('POST /api/sessions with resumeSessionId', () => {
|
||||
it('creates session with valid resumeSessionId', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: {
|
||||
name: 'resume-test',
|
||||
mode: 'claude',
|
||||
workingDir: process.env.HOME || '/tmp',
|
||||
resumeSessionId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.session).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects invalid resumeSessionId format', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: {
|
||||
name: 'bad-resume',
|
||||
mode: 'claude',
|
||||
workingDir: process.env.HOME || '/tmp',
|
||||
resumeSessionId: 'not-a-uuid',
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('creates session without resumeSessionId (optional field)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: {
|
||||
name: 'no-resume',
|
||||
mode: 'claude',
|
||||
workingDir: process.env.HOME || '/tmp',
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* @fileoverview Tests for WebSocket terminal I/O route.
|
||||
*
|
||||
* Unlike other route tests that use app.inject(), WebSocket testing requires
|
||||
* a real listening server since inject() doesn't support upgrade requests.
|
||||
* Uses the `ws` package (transitive dep of @fastify/websocket) as the client.
|
||||
*
|
||||
* @dependency test/mocks/mock-route-context.ts (createMockRouteContext)
|
||||
* @dependency src/web/routes/ws-routes.ts (registerWsRoutes)
|
||||
* Port: 3170 (ws-routes tests)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import WebSocket from 'ws';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { registerWsRoutes } from '../../src/web/routes/ws-routes.js';
|
||||
import { MAX_INPUT_LENGTH } from '../../src/config/terminal-limits.js';
|
||||
|
||||
const PORT = 3170;
|
||||
|
||||
/** Helper: open a WebSocket connection and wait for it to reach OPEN state. */
|
||||
function connectWs(path: string, timeoutMs = 5000): Promise<WebSocket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('WS connection timeout')), timeoutMs);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}${path}`);
|
||||
ws.on('open', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(ws);
|
||||
});
|
||||
ws.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Helper: wait for the next WS message, parsed as JSON. */
|
||||
function nextMessage(ws: WebSocket, timeoutMs = 2000): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('WS message timeout')), timeoutMs);
|
||||
ws.once('message', (raw) => {
|
||||
clearTimeout(timer);
|
||||
resolve(JSON.parse(String(raw)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Helper: wait for WS close event and return { code, reason }. */
|
||||
function waitForClose(ws: WebSocket, timeoutMs = 2000): Promise<{ code: number; reason: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('WS close timeout')), timeoutMs);
|
||||
ws.on('close', (code, reason) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, reason: reason.toString() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Helper: collect N messages from a WebSocket. */
|
||||
function collectMessages(ws: WebSocket, count: number, timeoutMs = 3000): Promise<unknown[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Only received ${msgs.length}/${count} messages`)), timeoutMs);
|
||||
const msgs: unknown[] = [];
|
||||
const onMessage = (raw: WebSocket.RawData) => {
|
||||
msgs.push(JSON.parse(String(raw)));
|
||||
if (msgs.length >= count) {
|
||||
clearTimeout(timer);
|
||||
ws.off('message', onMessage);
|
||||
resolve(msgs);
|
||||
}
|
||||
};
|
||||
ws.on('message', onMessage);
|
||||
});
|
||||
}
|
||||
|
||||
describe('ws-routes', () => {
|
||||
let app: FastifyInstance;
|
||||
let ctx: MockRouteContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
ctx = createMockRouteContext({ sessionId: 'ws-test-session' });
|
||||
registerWsRoutes(app, ctx as never);
|
||||
|
||||
await app.listen({ port: PORT, host: '127.0.0.1' });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ========== Session not found ==========
|
||||
|
||||
describe('session not found', () => {
|
||||
it('closes with 4004 when session does not exist', async () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws/sessions/nonexistent/terminal`);
|
||||
const { code, reason } = await waitForClose(ws);
|
||||
expect(code).toBe(4004);
|
||||
expect(reason).toBe('Session not found');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Terminal output ==========
|
||||
|
||||
describe('terminal output', () => {
|
||||
it('receives terminal output via WS with DEC 2026 sync markers', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
// Emit terminal data from mock session
|
||||
session.emit('terminal', 'hello world');
|
||||
|
||||
// Wait for the micro-batched message (8ms batch interval + margin)
|
||||
const msg = (await nextMessage(ws)) as { t: string; d: string };
|
||||
expect(msg.t).toBe('o');
|
||||
// Should contain DEC 2026 sync markers wrapping the data
|
||||
expect(msg.d).toContain('hello world');
|
||||
expect(msg.d).toMatch(/^\x1b\[\?2026h/); // starts with DEC 2026 start
|
||||
expect(msg.d).toMatch(/\x1b\[\?2026l$/); // ends with DEC 2026 end
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('sends clearTerminal event as {"t":"c"}', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
ctx._session.emit('clearTerminal');
|
||||
|
||||
const msg = (await nextMessage(ws)) as { t: string };
|
||||
expect(msg.t).toBe('c');
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('sends needsRefresh event as {"t":"r"}', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
ctx._session.emit('needsRefresh');
|
||||
|
||||
const msg = (await nextMessage(ws)) as { t: string };
|
||||
expect(msg.t).toBe('r');
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('coalesces rapid terminal emissions into a single frame', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
// Emit multiple small chunks in rapid succession (within the 8ms batch window)
|
||||
session.emit('terminal', 'chunk1');
|
||||
session.emit('terminal', 'chunk2');
|
||||
session.emit('terminal', 'chunk3');
|
||||
|
||||
// Should arrive as a single coalesced message
|
||||
const msg = (await nextMessage(ws)) as { t: string; d: string };
|
||||
expect(msg.t).toBe('o');
|
||||
expect(msg.d).toContain('chunk1chunk2chunk3');
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('flushes immediately when batch exceeds size threshold', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
// Emit data larger than WS_BATCH_FLUSH_THRESHOLD (16384)
|
||||
const largeData = 'X'.repeat(17000);
|
||||
session.emit('terminal', largeData);
|
||||
|
||||
// Should flush immediately (no 8ms wait) — use a tight timeout
|
||||
const msg = (await nextMessage(ws, 500)) as { t: string; d: string };
|
||||
expect(msg.t).toBe('o');
|
||||
expect(msg.d).toContain(largeData);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Client input ==========
|
||||
|
||||
describe('client input', () => {
|
||||
it('forwards input messages to session.write()', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'i', d: 'ls -la\r' }));
|
||||
|
||||
// Give the message handler time to process
|
||||
await vi.waitFor(() => {
|
||||
expect(session.writeBuffer).toContain('ls -la\r');
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores input exceeding MAX_INPUT_LENGTH', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
const hugeInput = 'x'.repeat(MAX_INPUT_LENGTH + 1);
|
||||
ws.send(JSON.stringify({ t: 'i', d: hugeInput }));
|
||||
|
||||
// Send a valid message after to confirm the connection still works
|
||||
ws.send(JSON.stringify({ t: 'i', d: 'ok' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.writeBuffer).toContain('ok');
|
||||
});
|
||||
|
||||
// The oversized input should not have been written
|
||||
expect(session.writeBuffer).not.toContain(hugeInput);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores malformed JSON messages', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send('not-json{{{');
|
||||
|
||||
// Send valid input to verify connection still alive
|
||||
ws.send(JSON.stringify({ t: 'i', d: 'after-bad' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.writeBuffer).toContain('after-bad');
|
||||
});
|
||||
|
||||
// Only 'after-bad' should be in the buffer
|
||||
expect(session.writeBuffer).toHaveLength(1);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores unknown message types without breaking the connection', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
// Send unknown type
|
||||
ws.send(JSON.stringify({ t: 'x', d: 'mystery' }));
|
||||
|
||||
// Connection should still work
|
||||
ws.send(JSON.stringify({ t: 'i', d: 'still-alive' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.writeBuffer).toContain('still-alive');
|
||||
});
|
||||
|
||||
// Unknown type should not have been written
|
||||
expect(session.writeBuffer).toHaveLength(1);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Resize validation ==========
|
||||
|
||||
describe('resize validation', () => {
|
||||
it('accepts valid resize within bounds', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: 120, r: 40 }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.resize).toHaveBeenCalledWith(120, 40);
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts resize at minimum bounds (1x1)', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: 1, r: 1 }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.resize).toHaveBeenCalledWith(1, 1);
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts resize at maximum bounds (500x200)', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: 500, r: 200 }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.resize).toHaveBeenCalledWith(500, 200);
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects resize with cols out of bounds (0 cols)', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: 0, r: 40 }));
|
||||
|
||||
// Send a valid message to confirm processing continues
|
||||
ws.send(JSON.stringify({ t: 'i', d: 'sentinel' }));
|
||||
await vi.waitFor(() => {
|
||||
expect(session.writeBuffer).toContain('sentinel');
|
||||
});
|
||||
|
||||
expect(session.resize).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects resize with cols exceeding 500', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: 501, r: 40 }));
|
||||
|
||||
ws.send(JSON.stringify({ t: 'i', d: 'sentinel' }));
|
||||
await vi.waitFor(() => {
|
||||
expect(session.writeBuffer).toContain('sentinel');
|
||||
});
|
||||
|
||||
expect(session.resize).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects resize with rows exceeding 200', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: 80, r: 201 }));
|
||||
|
||||
ws.send(JSON.stringify({ t: 'i', d: 'sentinel' }));
|
||||
await vi.waitFor(() => {
|
||||
expect(session.writeBuffer).toContain('sentinel');
|
||||
});
|
||||
|
||||
expect(session.resize).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects resize with non-integer values', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: 80.5, r: 24 }));
|
||||
|
||||
ws.send(JSON.stringify({ t: 'i', d: 'sentinel' }));
|
||||
await vi.waitFor(() => {
|
||||
expect(session.writeBuffer).toContain('sentinel');
|
||||
});
|
||||
|
||||
expect(session.resize).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects resize with negative values', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: -1, r: 24 }));
|
||||
|
||||
ws.send(JSON.stringify({ t: 'i', d: 'sentinel' }));
|
||||
await vi.waitFor(() => {
|
||||
expect(session.writeBuffer).toContain('sentinel');
|
||||
});
|
||||
|
||||
expect(session.resize).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Connection limit ==========
|
||||
|
||||
describe('connection limit', () => {
|
||||
it('closes with 4008 when too many connections per session', async () => {
|
||||
const connections: WebSocket[] = [];
|
||||
try {
|
||||
// Open 5 connections (the max)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
connections.push(await connectWs('/ws/sessions/ws-test-session/terminal'));
|
||||
}
|
||||
|
||||
// 6th connection should be rejected
|
||||
const ws6 = new WebSocket(`ws://127.0.0.1:${PORT}/ws/sessions/ws-test-session/terminal`);
|
||||
const { code, reason } = await waitForClose(ws6);
|
||||
expect(code).toBe(4008);
|
||||
expect(reason).toBe('Too many connections');
|
||||
} finally {
|
||||
for (const ws of connections) ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Heartbeat ==========
|
||||
|
||||
describe('heartbeat', () => {
|
||||
it('responds to server ping with pong (connection stays alive)', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
// The ws library automatically responds to pings with pongs.
|
||||
// Verify the connection survives by sending data after a brief delay.
|
||||
const session = ctx._session;
|
||||
session.emit('terminal', 'heartbeat-test');
|
||||
|
||||
const msg = (await nextMessage(ws)) as { t: string; d: string };
|
||||
expect(msg.t).toBe('o');
|
||||
expect(msg.d).toContain('heartbeat-test');
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== readyState guards ==========
|
||||
|
||||
describe('readyState guards', () => {
|
||||
it('does not throw when clearTerminal fires after close', async () => {
|
||||
const session = ctx._session;
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
|
||||
ws.close();
|
||||
await waitForClose(ws);
|
||||
|
||||
// These should be no-ops, not throw
|
||||
expect(() => session.emit('clearTerminal')).not.toThrow();
|
||||
expect(() => session.emit('needsRefresh')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Connection cleanup ==========
|
||||
|
||||
describe('connection cleanup', () => {
|
||||
it('removes session event listeners on close', async () => {
|
||||
const session = ctx._session;
|
||||
const listenersBefore = session.listenerCount('terminal');
|
||||
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
|
||||
// A listener was added for 'terminal'
|
||||
expect(session.listenerCount('terminal')).toBe(listenersBefore + 1);
|
||||
expect(session.listenerCount('clearTerminal')).toBeGreaterThanOrEqual(1);
|
||||
expect(session.listenerCount('needsRefresh')).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Close the WS connection
|
||||
ws.close();
|
||||
await waitForClose(ws);
|
||||
|
||||
// Wait for server-side close handler
|
||||
await vi.waitFor(() => {
|
||||
expect(session.listenerCount('terminal')).toBe(listenersBefore);
|
||||
});
|
||||
|
||||
expect(session.listenerCount('clearTerminal')).toBe(0);
|
||||
expect(session.listenerCount('needsRefresh')).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -100,11 +100,7 @@ function createAssistantTextEntry(text: string, timestamp?: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function createToolUseEntry(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
timestamp?: string
|
||||
): string {
|
||||
function createToolUseEntry(toolName: string, input: Record<string, unknown>, timestamp?: string): string {
|
||||
return JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: timestamp || new Date().toISOString(),
|
||||
@@ -134,6 +130,13 @@ function createToolResultEntry(content: string, timestamp?: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a mock readline interface with .close() method */
|
||||
function createMockRl() {
|
||||
const rl = new EventEmitter() as EventEmitter & { close: ReturnType<typeof vi.fn> };
|
||||
rl.close = vi.fn();
|
||||
return rl;
|
||||
}
|
||||
|
||||
describe('SubagentWatcher', () => {
|
||||
let watcher: SubagentWatcher;
|
||||
let mockExistsSync: Mock;
|
||||
@@ -166,12 +169,12 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
// Default mocks - no projects exist
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockStatSync.mockReturnValue({
|
||||
mockStatSync.mockImplementation(() => ({
|
||||
isDirectory: () => true,
|
||||
birthtime: new Date(),
|
||||
mtime: new Date(),
|
||||
size: 0,
|
||||
});
|
||||
}));
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
mockReadFileSync.mockReturnValue('');
|
||||
mockWatch.mockReturnValue({ close: vi.fn(), on: vi.fn(), off: vi.fn() });
|
||||
@@ -239,9 +242,9 @@ describe('SubagentWatcher', () => {
|
||||
const lines = [validEntry];
|
||||
|
||||
// Setup mock readline interface
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
// Setup file discovery
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
@@ -281,16 +284,11 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
it('should skip malformed JSON lines', async () => {
|
||||
const validEntry = createUserEntry('Valid entry');
|
||||
const malformedLines = [
|
||||
'not json at all',
|
||||
'{"incomplete": true',
|
||||
validEntry,
|
||||
'}{bad json}{',
|
||||
];
|
||||
const malformedLines = ['not json at all', '{"incomplete": true', validEntry, '}{bad json}{'];
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -330,9 +328,9 @@ describe('SubagentWatcher', () => {
|
||||
// Simulate partial write where line is incomplete
|
||||
const partialContent = '{"type": "user", "timestamp": "2024-01-01T00:00:00Z"';
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -369,9 +367,9 @@ describe('SubagentWatcher', () => {
|
||||
const validEntry = createUserEntry('Valid');
|
||||
const contentWithEmptyLines = ['', validEntry, ' ', '', validEntry].join('\n');
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -408,9 +406,9 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
describe('Status Lifecycle', () => {
|
||||
it('should start agents as active', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -442,9 +440,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should transition to idle after IDLE_TIMEOUT_MS (30s)', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -453,12 +451,12 @@ describe('SubagentWatcher', () => {
|
||||
if (path.includes('project1')) return ['session1'];
|
||||
return ['project1'];
|
||||
});
|
||||
mockStatSync.mockReturnValue({
|
||||
mockStatSync.mockImplementation(() => ({
|
||||
isDirectory: () => true,
|
||||
birthtime: new Date(),
|
||||
mtime: new Date(),
|
||||
size: 100,
|
||||
});
|
||||
}));
|
||||
mockReadFileSync.mockReturnValue(createUserEntry('Test subagent task'));
|
||||
|
||||
watcher.start();
|
||||
@@ -481,9 +479,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should reset to active on new activity', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
const mockWatcher = { close: vi.fn(), on: vi.fn(), off: vi.fn() };
|
||||
mockWatch.mockReturnValue(mockWatcher);
|
||||
@@ -495,12 +493,12 @@ describe('SubagentWatcher', () => {
|
||||
if (path.includes('project1')) return ['session1'];
|
||||
return ['project1'];
|
||||
});
|
||||
mockStatSync.mockReturnValue({
|
||||
mockStatSync.mockImplementation(() => ({
|
||||
isDirectory: () => true,
|
||||
birthtime: new Date(),
|
||||
mtime: new Date(),
|
||||
size: 100,
|
||||
});
|
||||
}));
|
||||
mockReadFileSync.mockReturnValue(createUserEntry('Test subagent task'));
|
||||
|
||||
watcher.start();
|
||||
@@ -514,19 +512,20 @@ describe('SubagentWatcher', () => {
|
||||
expect(watcher.getSubagents()[0].status).toBe('idle');
|
||||
|
||||
// Simulate file change event - get the callback from mockWatch
|
||||
const watchCallback = mockWatch.mock.calls.find(
|
||||
(call: unknown[]) => typeof call[1] === 'function'
|
||||
)?.[1];
|
||||
const watchCallback = mockWatch.mock.calls.find((call: unknown[]) => typeof call[1] === 'function')?.[1];
|
||||
|
||||
if (watchCallback) {
|
||||
// Need to reset the readline mock for the new read
|
||||
const newMockRl = new EventEmitter();
|
||||
const newMockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(newMockRl);
|
||||
|
||||
// Trigger file change
|
||||
watchCallback('change', 'agent-reactive.jsonl');
|
||||
|
||||
// Complete the new readline
|
||||
// Advance past the fileDeb debounce (100ms) so handleFileChange runs
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
// Complete the new readline (tailFile)
|
||||
newMockRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
@@ -536,9 +535,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should transition to completed when file becomes stale', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -588,9 +587,10 @@ describe('SubagentWatcher', () => {
|
||||
it('should extract tool_use entries', async () => {
|
||||
const toolEntry = createToolUseEntry('WebSearch', { query: 'test query' });
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
const descRl = createMockRl();
|
||||
const tailRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -612,9 +612,14 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('line', toolEntry);
|
||||
mockRl.emit('close');
|
||||
|
||||
// Resolve extractDescriptionFromFile
|
||||
descRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Now tailFile is set up — emit entries on tailRl
|
||||
tailRl.emit('line', toolEntry);
|
||||
tailRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(toolCallHandler).toHaveBeenCalled();
|
||||
@@ -626,9 +631,10 @@ describe('SubagentWatcher', () => {
|
||||
it('should extract text messages', async () => {
|
||||
const textEntry = createAssistantTextEntry('This is the assistant response');
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
const descRl = createMockRl();
|
||||
const tailRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -650,9 +656,14 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('line', textEntry);
|
||||
mockRl.emit('close');
|
||||
|
||||
// Resolve extractDescriptionFromFile
|
||||
descRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Now tailFile is set up — emit entries on tailRl
|
||||
tailRl.emit('line', textEntry);
|
||||
tailRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(messageHandler).toHaveBeenCalled();
|
||||
@@ -682,9 +693,9 @@ describe('SubagentWatcher', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -720,9 +731,10 @@ describe('SubagentWatcher', () => {
|
||||
const longText = 'x'.repeat(1000);
|
||||
const textEntry = createAssistantTextEntry(longText);
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
const descRl = createMockRl();
|
||||
const tailRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -744,9 +756,14 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('line', textEntry);
|
||||
mockRl.emit('close');
|
||||
|
||||
// Resolve extractDescriptionFromFile
|
||||
descRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Now tailFile is set up — emit entries on tailRl
|
||||
tailRl.emit('line', textEntry);
|
||||
tailRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(messageHandler).toHaveBeenCalled();
|
||||
@@ -757,9 +774,10 @@ describe('SubagentWatcher', () => {
|
||||
it('should extract progress events', async () => {
|
||||
const progressEntry = createProgressEntry('query_update', { query: 'searching for files' });
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
const descRl = createMockRl();
|
||||
const tailRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -781,9 +799,14 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('line', progressEntry);
|
||||
mockRl.emit('close');
|
||||
|
||||
// Resolve extractDescriptionFromFile
|
||||
descRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Now tailFile is set up — emit entries on tailRl
|
||||
tailRl.emit('line', progressEntry);
|
||||
tailRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(progressHandler).toHaveBeenCalled();
|
||||
@@ -795,9 +818,9 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
describe('Memory Management', () => {
|
||||
it('should track agents in agentInfo map', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -832,9 +855,10 @@ describe('SubagentWatcher', () => {
|
||||
const toolEntry1 = createToolUseEntry('Read', { file_path: '/test1.ts' });
|
||||
const toolEntry2 = createToolUseEntry('Write', { file_path: '/test2.ts' });
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
const descRl = createMockRl();
|
||||
const tailRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -853,10 +877,15 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('line', toolEntry1);
|
||||
mockRl.emit('line', toolEntry2);
|
||||
mockRl.emit('close');
|
||||
|
||||
// Resolve extractDescriptionFromFile
|
||||
descRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Now tailFile is set up — emit entries on tailRl
|
||||
tailRl.emit('line', toolEntry1);
|
||||
tailRl.emit('line', toolEntry2);
|
||||
tailRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
const agent = watcher.getSubagent('toolcount');
|
||||
@@ -871,9 +900,10 @@ describe('SubagentWatcher', () => {
|
||||
createToolUseEntry('Read', { file_path: '/test.ts' }),
|
||||
];
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
const descRl = createMockRl();
|
||||
const tailRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -892,11 +922,16 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
for (const entry of entries) {
|
||||
mockRl.emit('line', entry);
|
||||
}
|
||||
mockRl.emit('close');
|
||||
|
||||
// Resolve extractDescriptionFromFile
|
||||
descRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Now tailFile is set up — emit entries on tailRl
|
||||
for (const entry of entries) {
|
||||
tailRl.emit('line', entry);
|
||||
}
|
||||
tailRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
const agent = watcher.getSubagent('entrycount');
|
||||
@@ -907,9 +942,9 @@ describe('SubagentWatcher', () => {
|
||||
// Note: Current implementation has no cleanup/eviction policy
|
||||
// This documents the behavior as a known issue
|
||||
it('should retain all agents indefinitely (no cleanup policy)', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
|
||||
@@ -949,9 +984,9 @@ describe('SubagentWatcher', () => {
|
||||
createToolUseEntry('Read', { file_path: '/test.ts' }),
|
||||
];
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -979,13 +1014,11 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should limit transcript entries when limit is specified', async () => {
|
||||
const entries = Array.from({ length: 10 }, (_, i) =>
|
||||
createUserEntry(`Message ${i}`)
|
||||
);
|
||||
const entries = Array.from({ length: 10 }, (_, i) => createUserEntry(`Message ${i}`));
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1013,9 +1046,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should return empty array for unknown agent', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
@@ -1037,9 +1070,7 @@ describe('SubagentWatcher', () => {
|
||||
sessionId: 'sess1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'WebSearch', input: { query: 'test query' } },
|
||||
],
|
||||
content: [{ type: 'tool_use', name: 'WebSearch', input: { query: 'test query' } }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1092,9 +1123,9 @@ describe('SubagentWatcher', () => {
|
||||
it('should extract description from first user message', async () => {
|
||||
const userEntry = createUserEntry('Create comprehensive tests for the module');
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1116,6 +1147,7 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('line', userEntry);
|
||||
mockRl.emit('close');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -1134,9 +1166,9 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
const userEntry = createUserEntry(longPrompt);
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1158,6 +1190,7 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('line', userEntry);
|
||||
mockRl.emit('close');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -1169,9 +1202,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should emit subagent:updated when description is extracted from processEntry', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1234,7 +1267,7 @@ describe('SubagentWatcher', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
|
||||
// createReadStream now used for parent transcript reading (stream tail)
|
||||
@@ -1287,9 +1320,9 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
describe('getRecentSubagents', () => {
|
||||
it('should return only recent subagents', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1317,9 +1350,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should sort by lastActivityAt descending', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1354,9 +1387,9 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
describe('getSubagentsForSession', () => {
|
||||
it('should filter subagents by working directory', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1392,9 +1425,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should return false for already completed agent', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1428,9 +1461,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should emit completed event when killing active agent', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1485,9 +1518,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should handle readline errors gracefully', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1524,9 +1557,9 @@ describe('SubagentWatcher', () => {
|
||||
// Only directory watchers are created (no per-file watchers)
|
||||
mockWatch.mockReturnValue(mockDirWatcher);
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1556,9 +1589,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should clear idle timers on stop', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1596,9 +1629,9 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
describe('Project Hash Conversion', () => {
|
||||
it('should convert working directory to project hash format', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
|
||||
@@ -1640,9 +1673,7 @@ describe('SubagentWatcher', () => {
|
||||
sessionId: 'sess1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'WebSearch', input: { query: 'nodejs best practices' } },
|
||||
],
|
||||
content: [{ type: 'tool_use', name: 'WebSearch', input: { query: 'nodejs best practices' } }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1661,9 +1692,7 @@ describe('SubagentWatcher', () => {
|
||||
sessionId: 'sess1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'Read', input: { file_path: '/src/index.ts' } },
|
||||
],
|
||||
content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/src/index.ts' } }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1682,9 +1711,7 @@ describe('SubagentWatcher', () => {
|
||||
sessionId: 'sess1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'Bash', input: { command: 'npm test' } },
|
||||
],
|
||||
content: [{ type: 'tool_use', name: 'Bash', input: { command: 'npm test' } }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1704,9 +1731,7 @@ describe('SubagentWatcher', () => {
|
||||
sessionId: 'sess1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'Bash', input: { command: longCommand } },
|
||||
],
|
||||
content: [{ type: 'tool_use', name: 'Bash', input: { command: longCommand } }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1753,9 +1778,10 @@ describe('SubagentWatcher', () => {
|
||||
it('should emit user messages under 500 chars', async () => {
|
||||
const userEntry = createUserEntry('Short user message');
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
const descRl = createMockRl();
|
||||
const tailRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1777,9 +1803,14 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('line', userEntry);
|
||||
mockRl.emit('close');
|
||||
|
||||
// Resolve extractDescriptionFromFile
|
||||
descRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Now tailFile is set up — emit entries on tailRl
|
||||
tailRl.emit('line', userEntry);
|
||||
tailRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(messageHandler).toHaveBeenCalled();
|
||||
@@ -1790,9 +1821,9 @@ describe('SubagentWatcher', () => {
|
||||
it('should not emit long user messages (over 500 chars)', async () => {
|
||||
const longUserEntry = createUserEntry('x'.repeat(600));
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1820,9 +1851,7 @@ describe('SubagentWatcher', () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Long user messages are filtered out
|
||||
const userMessages = messageHandler.mock.calls.filter(
|
||||
(call) => (call[0] as SubagentMessage).role === 'user'
|
||||
);
|
||||
const userMessages = messageHandler.mock.calls.filter((call) => (call[0] as SubagentMessage).role === 'user');
|
||||
expect(userMessages.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1831,9 +1860,9 @@ describe('SubagentWatcher', () => {
|
||||
it('should not emit message for empty text content', async () => {
|
||||
const emptyTextEntry = createAssistantTextEntry(' ');
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
|
||||
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -16,69 +16,54 @@ import { IOSKeyboard } from '../components/IOSKeyboard';
|
||||
// ─── Scene timing (frames @ 30fps) ───
|
||||
const TITLE_DUR = 60;
|
||||
const PHONES_DUR = 30;
|
||||
const TYPING_DUR = 468;
|
||||
const HOLD_DUR = 60;
|
||||
const TYPING_DUR = 610;
|
||||
const HOLD_DUR = 50;
|
||||
const OUTRO_DUR = 45;
|
||||
|
||||
const TITLE_START = 0;
|
||||
const PHONES_START = TITLE_DUR; // 60
|
||||
const TYPING_START = PHONES_START + PHONES_DUR; // 90
|
||||
const HOLD_START = TYPING_START + TYPING_DUR; // 558
|
||||
const OUTRO_START = HOLD_START + HOLD_DUR; // 618
|
||||
const HOLD_START = TYPING_START + TYPING_DUR; // 700
|
||||
const OUTRO_START = HOLD_START + HOLD_DUR; // 750
|
||||
|
||||
export const ZEROLAG_TOTAL_FRAMES = OUTRO_START + OUTRO_DUR; // 663
|
||||
export const ZEROLAG_TOTAL_FRAMES = OUTRO_START + OUTRO_DUR; // 795
|
||||
|
||||
// ─── iPhone 17 Pro safe area ───
|
||||
const SAFE_AREA_TOP = 59; // Below Dynamic Island
|
||||
const PHONE_SCALE = 1.12; // Scale up to fill more of the frame
|
||||
|
||||
// The Codeman screenshot starts content at y=0 (the session tab).
|
||||
// On a real device it would sit below the safe area, so we offset it.
|
||||
const SCREENSHOT_Y_OFFSET = SAFE_AREA_TOP;
|
||||
// Claude Code header: tab bar + session info + prompt context from screenshot
|
||||
const HEADER_H = 120;
|
||||
|
||||
// Terminal typing overlay position (relative to screenshot top)
|
||||
// Session tab is ~44px, then terminal starts. Adding safe area offset:
|
||||
const TERMINAL_TOP = SAFE_AREA_TOP + 52;
|
||||
// Terminal typing overlay: aligned with the ❯ prompt position in the Claude Code screenshot
|
||||
const TERMINAL_TOP = 185;
|
||||
const TERMINAL_LEFT = 14;
|
||||
const TERMINAL_FONT = 22; // Large for video readability
|
||||
const TERMINAL_FONT = 21; // Slightly smaller to fit toolbar below
|
||||
|
||||
// ─── Typing schedule (with typo + backspace correction) ───
|
||||
const CORRECT_TEXT = 'fix the auth bug in the login flow';
|
||||
const FRAME_GAP = 12; // ~400ms between keystrokes
|
||||
const TYPO_INDEX = 28; // After "logi", type "m" instead of "n"
|
||||
// Codeman toolbar from screenshot (bottom section showing /init, /clear, Run, etc.)
|
||||
const TOOLBAR_H = 95;
|
||||
|
||||
// Remote connection lag: 600ms-1.2s+ per char (18-36+ frames)
|
||||
// ─── Typing schedule ───
|
||||
const CORRECT_TEXT =
|
||||
'zerolag technology brings in a visual dom overlay to make typing instant, even if your codeman server is on the other side of the world';
|
||||
const FRAME_GAP = 4; // ~133ms per keystroke (~75 WPM)
|
||||
|
||||
// Remote connection lag: 600ms–2.7s per char (18–80 frames @ 30fps).
|
||||
// Periodic spikes simulate packet loss / retransmission bursts.
|
||||
// TCP head-of-line blocking causes a single spike to freeze all subsequent chars.
|
||||
const LAGGY_DELAYS = [
|
||||
24, 30, 36, 32, 26, 22, 34, 28, 38, 20, 30, 24, 32, 26, 36, 22,
|
||||
30, 24, 32, 28, 34, 26, 30, 22, 28, 36, 24, 30, 32, 26, 34, 28, 24, 30,
|
||||
26, 32, 28, 34,
|
||||
24, 30, 26, 32, 72, 28, 22, 34, 26, 30, 20, 28, 36, 24, 30, 22, 26, 34, 28, 20, 68, 30, 24, 32,
|
||||
26, 22, 28, 34, 30, 26, 32, 24, 80, 22, 30, 26, 28, 34, 24, 30,
|
||||
];
|
||||
|
||||
type KeyAction = { frame: number; action: 'type' | 'backspace'; char: string; lagDelay: number };
|
||||
type KeyAction = { frame: number; char: string; lagDelay: number };
|
||||
|
||||
const buildSchedule = (): KeyAction[] => {
|
||||
const actions: KeyAction[] = [];
|
||||
let idx = 0;
|
||||
const lag = (i: number) => LAGGY_DELAYS[i % LAGGY_DELAYS.length];
|
||||
|
||||
// Type correctly up to typo point: "fix the auth bug in the logi"
|
||||
for (let i = 0; i < TYPO_INDEX; i++) {
|
||||
actions.push({ frame: idx * FRAME_GAP, action: 'type', char: CORRECT_TEXT[i], lagDelay: lag(idx) });
|
||||
idx++;
|
||||
}
|
||||
|
||||
// Typo: type "m" instead of "n"
|
||||
actions.push({ frame: idx * FRAME_GAP, action: 'type', char: 'm', lagDelay: lag(idx) });
|
||||
idx++;
|
||||
|
||||
// Backspace to fix it
|
||||
actions.push({ frame: idx * FRAME_GAP, action: 'backspace', char: '⌫', lagDelay: lag(idx) });
|
||||
idx++;
|
||||
|
||||
// Type correct remaining: "n flow"
|
||||
for (let i = TYPO_INDEX; i < CORRECT_TEXT.length; i++) {
|
||||
actions.push({ frame: idx * FRAME_GAP, action: 'type', char: CORRECT_TEXT[i], lagDelay: lag(idx) });
|
||||
idx++;
|
||||
for (let i = 0; i < CORRECT_TEXT.length; i++) {
|
||||
actions.push({ frame: i * FRAME_GAP, char: CORRECT_TEXT[i], lagDelay: lag(i) });
|
||||
}
|
||||
|
||||
return actions;
|
||||
@@ -86,14 +71,16 @@ const buildSchedule = (): KeyAction[] => {
|
||||
|
||||
const TYPING_SCHEDULE = buildSchedule();
|
||||
|
||||
/** Replay actions in order up to current frame, computing the visible text buffer */
|
||||
/**
|
||||
* Replay actions in order up to current frame, computing the visible text buffer.
|
||||
* TCP-ordered: stops at first unresolved echo (head-of-line blocking).
|
||||
*/
|
||||
const computeVisibleText = (frame: number, withLag: boolean): string => {
|
||||
let buffer = '';
|
||||
for (const a of TYPING_SCHEDULE) {
|
||||
const threshold = withLag ? a.frame + a.lagDelay : a.frame;
|
||||
if (frame < threshold) break; // TCP-ordered: stop at first unresolved
|
||||
if (a.action === 'backspace') buffer = buffer.slice(0, -1);
|
||||
else buffer += a.char;
|
||||
if (frame < threshold) break;
|
||||
buffer += a.char;
|
||||
}
|
||||
return buffer;
|
||||
};
|
||||
@@ -128,8 +115,20 @@ const IOSStatusBar: React.FC = () => (
|
||||
</svg>
|
||||
{/* WiFi */}
|
||||
<svg width="16" height="12" viewBox="0 0 16 12">
|
||||
<path d="M4.5 8.5C5.5 7.2 6.7 6.5 8 6.5s2.5.7 3.5 2" stroke="#fff" strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
||||
<path d="M1.5 5.5C3.5 3 5.7 1.5 8 1.5s4.5 1.5 6.5 4" stroke="#fff" strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
||||
<path
|
||||
d="M4.5 8.5C5.5 7.2 6.7 6.5 8 6.5s2.5.7 3.5 2"
|
||||
stroke="#fff"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M1.5 5.5C3.5 3 5.7 1.5 8 1.5s4.5 1.5 6.5 4"
|
||||
stroke="#fff"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="8" cy="11" r="1.5" fill="#fff" />
|
||||
</svg>
|
||||
{/* Battery */}
|
||||
@@ -145,9 +144,8 @@ const IOSStatusBar: React.FC = () => (
|
||||
// ─── Typing overlay ───
|
||||
const TypingOverlay: React.FC<{
|
||||
typed: string;
|
||||
overlayChars?: { char: string; confirmed: boolean }[];
|
||||
cursorVisible: boolean;
|
||||
}> = ({ typed, overlayChars, cursorVisible }) => {
|
||||
}> = ({ typed, cursorVisible }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const cursorOpacity = cursorVisible ? (Math.floor(frame / 18) % 2 === 0 ? 0.85 : 0.5) : 0;
|
||||
const lineH = Math.round(TERMINAL_FONT * 1.4);
|
||||
@@ -167,13 +165,7 @@ const TypingOverlay: React.FC<{
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#339af0', fontWeight: 700 }}>{'❯ '}</span>
|
||||
{overlayChars
|
||||
? overlayChars.map((oc, i) => (
|
||||
<span key={i} style={{ color: oc.confirmed ? '#e0e0e0' : '#666' }}>
|
||||
{oc.char}
|
||||
</span>
|
||||
))
|
||||
: <span style={{ color: '#e0e0e0' }}>{typed}</span>}
|
||||
<span style={{ color: '#e0e0e0' }}>{typed}</span>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
@@ -188,37 +180,116 @@ const TypingOverlay: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Single phone: iPhone 17 Pro + real screenshot + typing + keyboard ───
|
||||
// ─── Single phone: iPhone 17 Pro + Claude Code header + typing + keyboard ───
|
||||
const MobileCodeman: React.FC<{
|
||||
typed: string;
|
||||
overlayChars?: { char: string; confirmed: boolean }[];
|
||||
cursorVisible: boolean;
|
||||
activeKey?: string;
|
||||
pressAge?: number;
|
||||
showKeyboard?: boolean;
|
||||
noAnimation?: boolean;
|
||||
}> = ({ typed, overlayChars, cursorVisible, activeKey, pressAge, showKeyboard = true, noAnimation }) => (
|
||||
showPromo?: boolean;
|
||||
}> = ({ typed, cursorVisible, activeKey, pressAge, showKeyboard = true, noAnimation, showPromo }) => (
|
||||
<IPhone17ProFrame noAnimation={noAnimation}>
|
||||
<div style={{ width: SCREEN_W, height: SCREEN_H, position: 'relative', overflow: 'hidden', background: '#000' }}>
|
||||
{/* Real Codeman mobile screenshot, pushed down by safe area */}
|
||||
<Img
|
||||
src={staticFile('mobile-claude.png')}
|
||||
style={{
|
||||
width: SCREEN_W,
|
||||
height: SCREEN_H - SCREENSHOT_Y_OFFSET,
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'top',
|
||||
position: 'absolute',
|
||||
top: SCREENSHOT_Y_OFFSET,
|
||||
left: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{ width: SCREEN_W, height: SCREEN_H, position: 'relative', overflow: 'hidden', background: '#0d0d0d' }}
|
||||
>
|
||||
{/* iOS status bar in the safe area */}
|
||||
<IOSStatusBar />
|
||||
|
||||
{/* Claude Code header from screenshot (tabs + session info) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: SAFE_AREA_TOP,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: HEADER_H,
|
||||
overflow: 'hidden',
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={staticFile('mobile-claude.png')}
|
||||
style={{
|
||||
width: SCREEN_W,
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'top left',
|
||||
}}
|
||||
/>
|
||||
{/* Fade to terminal background */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 30,
|
||||
background: 'linear-gradient(transparent, #0d0d0d)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dark mask: hides screenshot text below Claude Code info (e.g. "Try edit...") */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: SAFE_AREA_TOP + 95,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 8,
|
||||
}}
|
||||
>
|
||||
{/* Smooth gradient blend from screenshot into dark terminal */}
|
||||
<div
|
||||
style={{
|
||||
height: 18,
|
||||
background: 'linear-gradient(transparent, #0d0d0d)',
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, background: '#0d0d0d' }} />
|
||||
</div>
|
||||
|
||||
{/* Typing animation */}
|
||||
<TypingOverlay typed={typed} overlayChars={overlayChars} cursorVisible={cursorVisible} />
|
||||
<TypingOverlay typed={typed} cursorVisible={cursorVisible} />
|
||||
|
||||
{/* Codeman toolbar from screenshot (/init, /clear, /compact, Run, Run Shell, voice) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 232,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: TOOLBAR_H,
|
||||
overflow: 'hidden',
|
||||
zIndex: 15,
|
||||
}}
|
||||
>
|
||||
{/* Gradient blend at top */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 18,
|
||||
background: 'linear-gradient(#0d0d0d, transparent)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<Img
|
||||
src={staticFile('mobile-claude.png')}
|
||||
style={{
|
||||
width: SCREEN_W,
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Promo cards between text and toolbar (left phone only) */}
|
||||
{showPromo && <PromoBanners />}
|
||||
|
||||
{/* iOS keyboard at bottom */}
|
||||
{showKeyboard && (
|
||||
@@ -230,22 +301,22 @@ const MobileCodeman: React.FC<{
|
||||
</IPhone17ProFrame>
|
||||
);
|
||||
|
||||
// ─── Label beneath phone ───
|
||||
// ─── Label above phone (prominent header) ───
|
||||
const PhoneLabel: React.FC<{
|
||||
title: string;
|
||||
detail: string;
|
||||
dotColor: string;
|
||||
detailColor: string;
|
||||
}> = ({ title, detail, dotColor, detailColor }) => (
|
||||
<div style={{ textAlign: 'center', marginTop: 20 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
fontFamily: fonts.ui,
|
||||
color: '#fff',
|
||||
}}
|
||||
@@ -267,6 +338,62 @@ const PhoneLabel: React.FC<{
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Promo banners (inside left phone, between text and keyboard) ───
|
||||
const PromoBanners: React.FC = () => (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 332,
|
||||
left: 8,
|
||||
right: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
zIndex: 15,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '12px 14px',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
<svg width="30" height="30" viewBox="0 0 16 16" fill="#ccc" style={{ flexShrink: 0 }}>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, fontFamily: fonts.ui, color: '#e0e0e0' }}>Codeman</div>
|
||||
<div style={{ fontSize: 12, fontFamily: fonts.mono, color: '#888' }}>github.com/Ark0N/Codeman</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '12px 14px',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
<svg width="30" height="30" viewBox="0 0 16 16" style={{ flexShrink: 0 }}>
|
||||
<rect width="16" height="16" rx="2" fill="#cb3837" />
|
||||
<path d="M3 3h10v10H8V5H5v8H3z" fill="#fff" />
|
||||
</svg>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, fontFamily: fonts.ui, color: '#e0e0e0' }}>xterm-zerolag-input</div>
|
||||
<div style={{ fontSize: 12, fontFamily: fonts.mono, color: '#888' }}>npmjs.com/package/xterm-zerolag-input</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Title scene ───
|
||||
const TitleScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
@@ -357,12 +484,22 @@ const TypingDemo: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<MobileCodeman typed={zerolagTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
|
||||
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
|
||||
<PhoneLabel
|
||||
title="With Zerolag"
|
||||
detail="0ms local echo"
|
||||
dotColor={colors.accent.green}
|
||||
detailColor={colors.accent.green}
|
||||
/>
|
||||
<MobileCodeman typed={zerolagTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation showPromo />
|
||||
</div>
|
||||
<div>
|
||||
<PhoneLabel
|
||||
title="Without Zerolag"
|
||||
detail="600ms–2.7s server echo"
|
||||
dotColor={colors.accent.red}
|
||||
detailColor={colors.accent.red}
|
||||
/>
|
||||
<MobileCodeman typed={laggyTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
|
||||
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
@@ -389,12 +526,22 @@ const PanelsEntrance: React.FC = () => {
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 50, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<PhoneLabel
|
||||
title="With Zerolag"
|
||||
detail="0ms local echo"
|
||||
dotColor={colors.accent.green}
|
||||
detailColor={colors.accent.green}
|
||||
/>
|
||||
<MobileCodeman typed="" cursorVisible noAnimation />
|
||||
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
|
||||
</div>
|
||||
<div>
|
||||
<PhoneLabel
|
||||
title="Without Zerolag"
|
||||
detail="600ms–2.7s server echo"
|
||||
dotColor={colors.accent.red}
|
||||
detailColor={colors.accent.red}
|
||||
/>
|
||||
<MobileCodeman typed="" cursorVisible noAnimation />
|
||||
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 390 KiB |