mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7752325c90 | ||
|
|
6b284598cf | ||
|
|
94bcf524a2 | ||
|
|
98966def03 | ||
|
|
e87b03b6c2 | ||
|
|
edd494ec5f | ||
|
|
00721069e1 | ||
|
|
453a5383d2 | ||
|
|
e7b95ae579 | ||
|
|
56c2c29009 | ||
|
|
7beec7194a | ||
|
|
dcc814f40c | ||
|
|
b7e94e7068 | ||
|
|
eade261763 | ||
|
|
41a82fcf02 | ||
|
|
eecf74c001 |
@@ -1,5 +1,52 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.6.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Terminal renderer hardening, SSE bandwidth cut, image paste, and a security tightening on the new live filter:
|
||||
- **Multi-primitive yield for write pacing** (#85): replaces six raw `requestAnimationFrame` callsites in the xterm.js write pipeline with a yielding helper that races `requestAnimationFrame`, `setTimeout(50)`, and a tick Worker. Keeps the terminal responsive when the tab is backgrounded or occluded — Chrome's intensive-throttling no longer stalls long writes.
|
||||
- **WebGL longtask auto-fallback** (#83): a `PerformanceObserver` watches for ≥200ms WebGL frames; three within a 30s window disposes the WebGL addon and falls back to the canvas renderer. Decision is persisted in localStorage for 7 days, and `?webgl=force` clears it.
|
||||
- **Per-client live SSE subscription filter** (#86): each connected client gets a stable UUID and can narrow its terminal stream to one session via `POST /api/events/subscribe` — no EventSource reconnect on tab switches. Cuts SSE bandwidth roughly N× when N sessions are open. Lifecycle/metadata events (`session:*`, `case:*`, `ralph:*`, `hook:*`) now broadcast to every client so sidebars stay in sync.
|
||||
- **Image paste and drag-and-drop into the terminal** (#84): `Ctrl+V` and dropped images upload to `POST /api/sessions/:id/paste-image`, save under `${workingDir}/.claude-images/paste-${ts}.${ext}` and type the path into the terminal. Hard 10MB cap, server-generated filename (no traversal), `.svg` deliberately excluded from the allowlist to avoid a same-origin XSS path through `file-raw`.
|
||||
- **SSE clientId validation**: the per-client identifier introduced in #86 is now constrained to `[A-Za-z0-9_-]{8,64}` at both ingress points. Without this, an authenticated attacker could send another tab's clientId to silently evict it from broadcasts, mutate any clientId's session filter to blackhole the victim's terminal stream, or grow `sseClientsById` unboundedly via long IDs. The subscribe payload is also capped at 64 session entries of ≤128 chars each.
|
||||
|
||||
## 0.6.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Finish the hostname-aware notification plumbing started in 0.6.7 and lock down the recent UI/runtime fixes with regression tests.
|
||||
- Browser Notification API (OS-level desktop pop-ups, layer 3 of the 5-layer notification system) now uses `${originalTitle}: ${title}` instead of the hardcoded `Codeman:` literal — so multi-host users running Codeman on laptop / dev box / NAS see `codeman:<host>: <event>` consistently across tab title, tab-flash, Web Push, and OS notifications.
|
||||
- Inline session rename hardened against three corner cases: IME composition commits (Chinese pinyin Enter no longer ships half-composed text as the session name), mid-rename SSE deletion (orphaned `<input>` no longer 404s on blur), and double-fire on stuck settle-once flag (closure-local `settled` boolean replaces the boolean instance flag).
|
||||
- Test coverage backfilled for two prior shipped fixes:
|
||||
- `<title>codeman:<host></title>` server-side templating (#82): 8 tests covering default `os.hostname()`, `--title-hostname` override, HTML-escape against `<script>`-style breakout, ampersand non-double-encoding, and template-tail byte-identical invariance.
|
||||
- tmux size-query helper (#80): 15 tests covering the browser-resize-between-attaches happy path, the query-then-die race, zero/negative/empty/non-numeric output fallbacks, and argv-form/timeout assertions that lock down the no-shell-interpolation guarantee. Inline 14-line query block extracted into a named `queryTmuxWindowSize()` export in `session.ts` so the test surface is a pure function.
|
||||
- Regression coverage added for `stripInkRedrawBloat` route helper.
|
||||
- CLAUDE.md and README.md updated to document dual-CLI env-prefix discipline (`CLAUDE_CODE_*` vs `OPENCODE_*`), the `xterm-zerolag-input` published-package side-effect of overlay edits, and the unified hostname prefix across tab title / tab-flash / OS notifications.
|
||||
|
||||
## 0.6.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- - **fix(client): preserve inline rename input across tab re-renders** (#81) — Right-click → rename on a session tab no longer loses keystrokes when SSE traffic from sibling sessions triggers a tab re-render. Adds an `_inlineRenameActive` guard at the top of `renderSessionTabs()` and `_fullRenameSessionTabs()` so the in-progress input isn't destroyed mid-typing. Also fixes a latent double-fire of `finishRename` (blur + Enter could both invoke it). Drive-by: safer DOM child clearing in place of `innerHTML = ''`.
|
||||
- **feat: hostname-aware window title** (#82) — The browser tab title is now `codeman:<hostname>` instead of the bare `Codeman` literal, so users running Codeman on multiple hosts (laptop, dev box, NAS) can tell at a glance which tab points at which backend. New `--title-hostname <name>` CLI flag overrides the detected `os.hostname()` when it's noisy or you want a cosmetic name. The title is templated into the served HTML on first byte (with narrow HTML escaping), so it's correct from the first paint and works without JavaScript. Title-flash logic now respects the per-host title.
|
||||
- **perf: larger terminal tail on tab switch** — `TERMINAL_TAIL_SIZE` raised from 128KB to 1MB. When switching back to a busy session tab you now get ~8× more scrollback restored immediately.
|
||||
- **fix: preserve response text in Ink redraw stripping** — `stripInkRedrawBloat()` rewritten from a first-VPA approach to cluster-based detection. The previous algorithm assumed all VPA escapes after the first one belonged to a single redraw region and discarded everything in between, which silently lost 100KB+ of legitimate Claude response text once a render had occurred. The new approach groups VPAs into clusters separated by ≥8KB gaps and only collapses clusters spanning ≥32KB, so streamed response content between redraw bursts is preserved.
|
||||
- **docs**: `CLAUDE.md` Additional Commands gains the `--title-hostname` row; `README.md` gets a "Hostname-Aware Window Title" subsection under Multi-Session Dashboard.
|
||||
|
||||
## 0.6.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **Terminal scrollback significantly increased** — both the xterm.js viewport and the tmux backing buffer were bottlenecking how far back you could scroll. Three changes:
|
||||
- `DEFAULT_SCROLLBACK` raised from 20000 → 50000 lines (xterm.js, main terminal). The previous bump from 5000 only helped users with empty localStorage; existing users were stuck on whatever value they first picked up. The loader now treats `DEFAULT_SCROLLBACK` as a floor — if your stored value is below the new minimum, you're raised to it automatically.
|
||||
- Subagent / teammate terminals (`panels-ui.js`) were stuck at 5000; now use the same `DEFAULT_SCROLLBACK` constant (50000).
|
||||
- New tmux sessions now run with `history-limit 50000` (tmux defaults to 2000). This matters for hard-reload / re-attach — without it, only the last ~2000 lines survive the round-trip back into a fresh xterm.
|
||||
|
||||
**Tmux flicker on session re-attach fixed (PR #80 by @aakhter)**: the PTY now queries the existing tmux window size via `tmux display -p` before spawning, instead of hardcoding 120x40. Previously, every re-attach forced tmux to resize down to 120x40, causing a visible flicker and one frame of scrollback loss. The `-x 120 -y 40` flag was also dropped from `tmux new-session` so the initial size matches the first attaching client. Uses `execFileSync` (not shell) for safety and falls back to 120x40 on any error.
|
||||
|
||||
**Docs**: CLAUDE.md now documents two recurring foot-guns — the `xterm-zerolag-input` overlay code is duplicated between `packages/xterm-zerolag-input/src/` and inline inside `src/web/public/app.js`, so any overlay change must touch both; and the COM workflow explicitly includes a post-push `gh run watch` step to confirm CI before considering the release done.
|
||||
|
||||
## 0.6.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -52,10 +52,11 @@ When user says "COM":
|
||||
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions)
|
||||
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`
|
||||
6. **Wait for CI**: after `git push`, find the run with `gh run list -L 1 --json databaseId,headBranch -q '.[0].databaseId'` and watch it with `gh run watch <id> --exit-status`. Confirm all checks pass before considering the release done.
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 0.6.5 (must match `package.json`)
|
||||
**Version**: 0.6.9 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -76,6 +77,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Dev with TLS | `npx tsx src/index.ts web --https` |
|
||||
| Override window title hostname | `npx tsx src/index.ts web --title-hostname <name>` (default: `os.hostname()` — `codeman:<name>` is used for tab title, title-flash, and OS desktop notification prefix) |
|
||||
| Continuous typecheck | `tsc --noEmit --watch` |
|
||||
| Test coverage | `npm run test:coverage` |
|
||||
| Dead-code sweep | `npm run knip` (config in `knip.json`) |
|
||||
@@ -93,7 +95,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly
|
||||
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
||||
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift
|
||||
- **Dual-CLI prefix discipline** — Codeman supports both Claude Code and OpenCode (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward both prefixes
|
||||
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
|
||||
- **`xterm-zerolag-input` is duplicated** — the local-echo overlay lives in BOTH `packages/xterm-zerolag-input/src/` (published to npm as a standalone library for external consumers — see README "Published Packages") AND inline inside `src/web/public/app.js` (runtime copy the web UI actually loads, since the page ships as plain JS without a bundler). Any change to overlay behavior MUST be applied to both, or dev and prod diverge — and a public API break in the package warrants a separate version bump for `xterm-zerolag-input` in the changeset. Always test on mobile after touching it.
|
||||
|
||||
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
||||
|
||||
|
||||
@@ -226,6 +226,17 @@ Run **20 parallel sessions** with full visibility — real-time xterm.js termina
|
||||
|
||||
Every session runs inside **tmux** — sessions survive server restarts, network drops, and machine sleep. Auto-recovery on startup with dual redundancy. Ghost session discovery finds orphaned tmux sessions. Managed sessions are environment-tagged so the agent won't kill its own session.
|
||||
|
||||
### Hostname-Aware Window Title
|
||||
|
||||
Running Codeman on multiple hosts (laptop, dev box, NAS)? The browser tab title is `codeman:<hostname>` so you can tell which backend each tab points at without clicking in:
|
||||
|
||||
```bash
|
||||
codeman web # codeman:<os.hostname()>
|
||||
codeman web --title-hostname dev-box # codeman:dev-box (manual override for noisy hostnames)
|
||||
```
|
||||
|
||||
The title is templated into the served HTML on first byte, so it's correct from the very first paint and works without JavaScript. The same hostname prefix is applied to the tab-flash format (`⚠️ (N) codeman:<host>`) and to OS-level desktop notifications (`codeman:<host>: <event>`), so cross-host alerts in the system notification center are also unambiguous.
|
||||
|
||||
### Smart Token Management
|
||||
|
||||
| Threshold | Action | Result |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.6.5",
|
||||
"version": "0.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "0.6.5",
|
||||
"version": "0.6.9",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.6.5",
|
||||
"version": "0.6.9",
|
||||
"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",
|
||||
|
||||
@@ -19,6 +19,10 @@ const PORTS = {
|
||||
|
||||
const results = [];
|
||||
|
||||
function isCodemanTitle(title) {
|
||||
return typeof title === 'string' && title.startsWith('codeman:');
|
||||
}
|
||||
|
||||
function logSection(title) {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(` ${title}`);
|
||||
@@ -88,7 +92,7 @@ async function main() {
|
||||
const page = await playwrightBrowser.newPage();
|
||||
await page.goto(`http://localhost:${PORTS.playwright}`);
|
||||
const title = await page.title();
|
||||
if (title !== 'Codeman') throw new Error(`Expected Codeman, got ${title}`);
|
||||
if (!isCodemanTitle(title)) throw new Error(`Expected codeman:<hostname>, got ${title}`);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
@@ -149,7 +153,7 @@ async function main() {
|
||||
const page = await puppeteerBrowser.newPage();
|
||||
await page.goto(`http://localhost:${PORTS.puppeteer}`);
|
||||
const title = await page.title();
|
||||
if (title !== 'Codeman') throw new Error(`Expected Codeman, got ${title}`);
|
||||
if (!isCodemanTitle(title)) throw new Error(`Expected codeman:<hostname>, got ${title}`);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
@@ -202,7 +206,7 @@ async function main() {
|
||||
agentBrowser(`open http://localhost:${PORTS.agentBrowser}`);
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
const title = agentBrowserJson('get title');
|
||||
agentBrowserAvailable = title.title === 'Codeman';
|
||||
agentBrowserAvailable = isCodemanTitle(title.title);
|
||||
console.log(' Browser launched');
|
||||
|
||||
// Test 1: Page load
|
||||
@@ -210,7 +214,7 @@ async function main() {
|
||||
agentBrowser(`open http://localhost:${PORTS.agentBrowser}`);
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
const title = agentBrowserJson('get title');
|
||||
if (title.title !== 'Codeman') throw new Error(`Expected Codeman, got ${title.title}`);
|
||||
if (!isCodemanTitle(title.title)) throw new Error(`Expected codeman:<hostname>, got ${title.title}`);
|
||||
});
|
||||
|
||||
// Test 2: Element selection
|
||||
|
||||
@@ -93,6 +93,7 @@ console.log('\n[build] content-hash cache busting');
|
||||
'ralph-wizard.js',
|
||||
'api-client.js',
|
||||
'subagent-windows.js',
|
||||
'image-input.js',
|
||||
'vendor/xterm-zerolag-input.js',
|
||||
];
|
||||
const manifest = {};
|
||||
|
||||
+3
-1
@@ -485,16 +485,18 @@ program
|
||||
.description('Start the web interface')
|
||||
.option('-p, --port <port>', 'Port to listen on', '3000')
|
||||
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
|
||||
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
|
||||
.action(async (options) => {
|
||||
const { startWebServer } = await import('./web/server.js');
|
||||
const port = parseInt(options.port, 10);
|
||||
const https = !!options.https;
|
||||
const titleHostname = options.titleHostname;
|
||||
const protocol = https ? 'https' : 'http';
|
||||
|
||||
console.log(chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`));
|
||||
|
||||
try {
|
||||
const server = await startWebServer(port, https);
|
||||
const server = await startWebServer(port, https, false, titleHostname);
|
||||
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://localhost:${port}`));
|
||||
if (https) {
|
||||
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
|
||||
|
||||
+36
-3
@@ -29,7 +29,7 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { execSync, execFileSync } from 'node:child_process';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as pty from 'node-pty';
|
||||
import {
|
||||
@@ -121,6 +121,37 @@ const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
||||
|
||||
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
||||
|
||||
/** PTY fallback geometry when tmux can't be queried (matches pre-#80 hardcoded values). */
|
||||
const DEFAULT_PTY_COLS = 120;
|
||||
const DEFAULT_PTY_ROWS = 40;
|
||||
const TMUX_DISPLAY_TIMEOUT_MS = 2000;
|
||||
|
||||
/**
|
||||
* Ask tmux for the current window geometry of `muxName` so a re-attaching PTY
|
||||
* client can spawn at the same size and avoid the resize-flicker / scrollback
|
||||
* loss documented in #80. Returns `{ cols: 120, rows: 40 }` on any failure
|
||||
* (tmux dead, muxName unknown, malformed output) — caller never has to
|
||||
* differentiate "tmux unreachable" from "size 120x40".
|
||||
*
|
||||
* Argv form (execFileSync, not execSync) keeps `muxName` out of any shell so
|
||||
* a hostile session name can't inject options.
|
||||
*/
|
||||
export function queryTmuxWindowSize(muxName: string): { cols: number; rows: number } {
|
||||
try {
|
||||
const sizeStr = execFileSync('tmux', ['display', '-t', muxName, '-p', '#{window_width} #{window_height}'], {
|
||||
timeout: TMUX_DISPLAY_TIMEOUT_MS,
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
const [w, h] = sizeStr.split(' ').map(Number);
|
||||
if (w > 0 && h > 0) {
|
||||
return { cols: w, rows: h };
|
||||
}
|
||||
} catch {
|
||||
/* fall back below */
|
||||
}
|
||||
return { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS };
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a JSON message from Claude CLI's stream-json output format.
|
||||
* Messages are newline-delimited JSON objects parsed from PTY output.
|
||||
@@ -946,11 +977,13 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
// Attach to the mux session via PTY
|
||||
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default)
|
||||
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName);
|
||||
try {
|
||||
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cols: ptyCols,
|
||||
rows: ptyRows,
|
||||
cwd: this.workingDir,
|
||||
env: buildMuxAttachEnv(),
|
||||
});
|
||||
|
||||
+8
-1
@@ -556,7 +556,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// (Production uses systemd which has a clean env, but dev/test may be nested.)
|
||||
const cleanEnv = { ...process.env };
|
||||
delete cleanEnv.TMUX;
|
||||
execSync(`tmux new-session -ds "${muxName}" -c "${workingDir}" -x 120 -y 40`, {
|
||||
execSync(`tmux new-session -ds "${muxName}" -c "${workingDir}"`, {
|
||||
cwd: workingDir,
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
@@ -609,6 +609,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
.catch(() => {
|
||||
/* Already set globally as fallback */
|
||||
}),
|
||||
// Raise tmux scrollback from its 2000-line default so re-attach preserves
|
||||
// more context. Matches the xterm-side default in constants.js.
|
||||
execAsync(`tmux set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS })
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
/* Non-critical — falls back to tmux default */
|
||||
}),
|
||||
];
|
||||
|
||||
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
|
||||
|
||||
+93
-2
@@ -286,6 +286,12 @@ class CodemanApp {
|
||||
this.totalTokens = 0;
|
||||
this.globalStats = null; // Global token/cost stats across all sessions
|
||||
this.eventSource = null;
|
||||
// Stable per-page client ID — lets the server target this connection
|
||||
// for live filter updates (POST /api/events/subscribe) without forcing
|
||||
// an SSE reconnect on session switches.
|
||||
this._clientId = (typeof crypto !== 'undefined' && crypto.randomUUID)
|
||||
? crypto.randomUUID()
|
||||
: 'c-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
this.terminal = null;
|
||||
this.fitAddon = null;
|
||||
this.activeSessionId = null;
|
||||
@@ -617,14 +623,57 @@ class CodemanApp {
|
||||
this._webglAddon = new WebglAddon.WebglAddon();
|
||||
this._webglAddon.onContextLoss(() => {
|
||||
console.error('[CRASH-DIAG] WebGL context LOST — falling back to canvas renderer');
|
||||
this._webglAddon.dispose();
|
||||
_crashDiag.log('WEBGL_LOST');
|
||||
this._disableWebGLSticky('context-lost');
|
||||
this._webglAddon?.dispose();
|
||||
this._webglAddon = null;
|
||||
});
|
||||
this.terminal.loadAddon(this._webglAddon);
|
||||
console.log('[CRASH-DIAG] WebGL renderer enabled');
|
||||
this._installWebGLLongTaskGuard();
|
||||
} catch (_e) { /* WebGL2 unavailable — canvas renderer used */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for sustained main-thread stalls that indicate WebGL/GPU trouble.
|
||||
* After 3 long tasks (>=200ms each) within 30s, dispose the WebGL addon and
|
||||
* persist a sticky disable so subsequent reloads also use the DOM renderer.
|
||||
* 5s grace period skips initial-load stalls. Force-re-enable: ?webgl=force.
|
||||
*/
|
||||
_installWebGLLongTaskGuard() {
|
||||
if (typeof PerformanceObserver === 'undefined' || this._webglLongTaskObserver) return;
|
||||
const installedAt = performance.now();
|
||||
const recent = [];
|
||||
try {
|
||||
this._webglLongTaskObserver = new PerformanceObserver((list) => {
|
||||
if (!this._webglAddon) return;
|
||||
const now = performance.now();
|
||||
if (now - installedAt < 5000) return;
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.duration >= 200) recent.push(entry.startTime);
|
||||
}
|
||||
while (recent.length && now - recent[0] > 30000) recent.shift();
|
||||
if (recent.length >= 3) {
|
||||
console.warn(`[CRASH-DIAG] WebGL long-task threshold (${recent.length} stalls/30s) — falling back to canvas renderer`);
|
||||
_crashDiag.log(`WEBGL_FALLBACK: ${recent.length}`);
|
||||
this._disableWebGLSticky('long-tasks');
|
||||
this._webglAddon?.dispose();
|
||||
this._webglAddon = null;
|
||||
try { this._webglLongTaskObserver.disconnect(); } catch {}
|
||||
this._webglLongTaskObserver = null;
|
||||
try { this.terminal.refresh(0, this.terminal.rows - 1); } catch {}
|
||||
}
|
||||
});
|
||||
this._webglLongTaskObserver.observe({ type: 'longtask', buffered: false });
|
||||
} catch { /* longtask not supported */ }
|
||||
}
|
||||
|
||||
_disableWebGLSticky(reason) {
|
||||
try {
|
||||
localStorage.setItem('codeman-webgl-disabled', JSON.stringify({ reason, at: Date.now() }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Event Listeners (Keyboard Shortcuts, Resize, Beforeunload)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -696,6 +745,28 @@ class CodemanApp {
|
||||
// SSE Connection
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* POST a live subscription update so the server filters terminal events
|
||||
* to the given session(s) for this client. Fire-and-forget — failures
|
||||
* are non-fatal because we'll still get every event we don't want
|
||||
* (just at higher cost), and the next reconnect carries the filter via
|
||||
* the SSE query string.
|
||||
*/
|
||||
_updateSseSubscription(sessionId) {
|
||||
try {
|
||||
const body = JSON.stringify({
|
||||
clientId: this._clientId,
|
||||
sessions: sessionId ? [sessionId] : null,
|
||||
});
|
||||
fetch('/api/events/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
keepalive: true,
|
||||
}).catch(() => { /* non-fatal */ });
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
connectSSE() {
|
||||
// Check if browser is offline
|
||||
if (!navigator.onLine) {
|
||||
@@ -725,7 +796,13 @@ class CodemanApp {
|
||||
this.setConnectionStatus('reconnecting');
|
||||
}
|
||||
|
||||
this.eventSource = new EventSource('/api/events');
|
||||
// Build URL with stable client ID and (if known) the active-session
|
||||
// filter so the server only streams session:terminal events for the
|
||||
// session we're rendering. Lifecycle/metadata events are sent globally
|
||||
// regardless of filter (server side).
|
||||
const _sseParams = new URLSearchParams({ clientId: this._clientId });
|
||||
if (this.activeSessionId) _sseParams.set('sessions', this.activeSessionId);
|
||||
this.eventSource = new EventSource(`/api/events?${_sseParams.toString()}`);
|
||||
|
||||
// Store all event listeners for cleanup on reconnect
|
||||
const listeners = [];
|
||||
@@ -1844,6 +1921,8 @@ class CodemanApp {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
renderSessionTabs() {
|
||||
// Don't re-render while user is typing in the inline rename input
|
||||
if (this._activeRename) return;
|
||||
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
|
||||
}
|
||||
|
||||
@@ -1988,6 +2067,7 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
_fullRenderSessionTabs() {
|
||||
if (this._activeRename) return;
|
||||
const container = this.$('sessionTabs');
|
||||
|
||||
// Clean up any orphaned dropdowns before re-rendering
|
||||
@@ -2421,6 +2501,12 @@ class CodemanApp {
|
||||
this._cleanupPreviousSession(sessionId);
|
||||
this.activeSessionId = sessionId;
|
||||
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
|
||||
// Narrow SSE filter to the active session — server stops streaming
|
||||
// session:terminal events for other sessions to this client. Cuts
|
||||
// SSE traffic ~Nx for N concurrent sessions. Fire-and-forget; on the
|
||||
// rare race where server doesn't know our clientId yet, the next
|
||||
// selectSession or reconnect catches up.
|
||||
this._updateSseSubscription(sessionId);
|
||||
this.hideWelcome();
|
||||
// Clear idle hooks on view, but keep action hooks until user interacts
|
||||
this.clearPendingHooks(sessionId, 'idle_prompt');
|
||||
@@ -2691,6 +2777,11 @@ class CodemanApp {
|
||||
|
||||
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler
|
||||
_cleanupSessionData(sessionId) {
|
||||
// If the deleted session is currently being renamed, abort the rename
|
||||
// so the inline <input> doesn't ghost as a stale tab on screen.
|
||||
if (this._activeRename?.sessionId === sessionId) {
|
||||
this._activeRename.cancel();
|
||||
}
|
||||
this.sessions.delete(sessionId);
|
||||
// Remove from tab order
|
||||
const orderIndex = this.sessionOrder.indexOf(sessionId);
|
||||
|
||||
@@ -43,7 +43,7 @@ function urlBase64ToUint8Array(base64String) {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Default terminal scrollback (can be changed via settings)
|
||||
const DEFAULT_SCROLLBACK = 20000;
|
||||
const DEFAULT_SCROLLBACK = 50000;
|
||||
|
||||
// Timing constants
|
||||
const STUCK_THRESHOLD_DEFAULT_MS = 600000; // 10 minutes - default for stuck detection
|
||||
@@ -54,7 +54,7 @@ const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notificati
|
||||
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
|
||||
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
||||
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
|
||||
const TERMINAL_TAIL_SIZE = 128 * 1024; // 128KB tail for initial load
|
||||
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
|
||||
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
||||
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Image Input Mixin - Clipboard paste and drag-and-drop image support
|
||||
*
|
||||
* For paste: intercepts Ctrl+V at the xterm keyboard level, creates a temporary
|
||||
* hidden contenteditable div ("paste trap"), lets the browser's native paste fill
|
||||
* it, then checks for image data. This works on HTTP (no secure context needed).
|
||||
*
|
||||
* For drag-and-drop: listens on the terminal container for file drops.
|
||||
*
|
||||
* @dependency app.js (uses global `app` for sendInput, activeSessionId, showToast)
|
||||
* @dependency panels-ui.js (provides showToast)
|
||||
*/
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
|
||||
initImageInput() {
|
||||
// Drag-and-drop handlers on terminal container
|
||||
const container = document.getElementById('terminalContainer');
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
|
||||
container.classList.add('drag-active');
|
||||
}
|
||||
});
|
||||
|
||||
container.addEventListener('dragleave', (e) => {
|
||||
if (!container.contains(e.relatedTarget)) {
|
||||
container.classList.remove('drag-active');
|
||||
}
|
||||
});
|
||||
|
||||
container.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
container.classList.remove('drag-active');
|
||||
|
||||
if (!this.activeSessionId) return;
|
||||
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
||||
|
||||
const imageFiles = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
||||
if (imageFiles.length === 0) {
|
||||
this.showToast('Only image files are supported', 'error');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages(imageFiles);
|
||||
});
|
||||
},
|
||||
|
||||
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
||||
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
||||
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
||||
_handleImagePaste() {
|
||||
const self = this;
|
||||
|
||||
// Create a hidden contenteditable div to receive the paste
|
||||
const trap = document.createElement('div');
|
||||
trap.contentEditable = 'true';
|
||||
trap.style.cssText = 'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;overflow:hidden';
|
||||
document.body.appendChild(trap);
|
||||
trap.focus();
|
||||
|
||||
// Listen for the paste event on our trap
|
||||
trap.addEventListener('paste', function(e) {
|
||||
e.stopPropagation();
|
||||
|
||||
// Check for images in clipboard items
|
||||
var imageFiles = [];
|
||||
var items = e.clipboardData && e.clipboardData.items;
|
||||
if (items) {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
var blob = items[i].getAsFile();
|
||||
if (blob) imageFiles.push(blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up the trap
|
||||
setTimeout(function() {
|
||||
if (trap.parentNode) trap.parentNode.removeChild(trap);
|
||||
// Refocus the terminal
|
||||
if (self.terminal) self.terminal.focus();
|
||||
}, 0);
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
self._uploadAndInsertImages(imageFiles);
|
||||
} else {
|
||||
// No image -- extract text and send to terminal
|
||||
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||
e.preventDefault();
|
||||
if (text) self.sendInput(text);
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger the browser's native paste via execCommand
|
||||
// (this fires the paste event on our focused trap element)
|
||||
document.execCommand('paste');
|
||||
},
|
||||
|
||||
async _uploadAndInsertImages(files) {
|
||||
const sessionId = this.activeSessionId;
|
||||
if (!sessionId) return;
|
||||
|
||||
this.showToast('Uploading ' + files.length + ' image' + (files.length > 1 ? 's' : '') + '...', 'info');
|
||||
|
||||
const paths = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const path = await this._uploadPasteImage(sessionId, file);
|
||||
paths.push(path);
|
||||
} catch (err) {
|
||||
this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
if (paths.length > 0) {
|
||||
const pathStr = paths.join(' ');
|
||||
await this.sendInput(pathStr);
|
||||
this.showToast(paths.length + ' image' + (paths.length > 1 ? 's' : '') + ' ready', 'success');
|
||||
}
|
||||
},
|
||||
|
||||
async _uploadPasteImage(sessionId, file) {
|
||||
const form = new FormData();
|
||||
form.append('image', file);
|
||||
|
||||
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'HTTP ' + resp.status);
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
return data.path;
|
||||
},
|
||||
|
||||
});
|
||||
@@ -1804,5 +1804,6 @@
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
<script defer src="subagent-windows.js"></script>
|
||||
<script defer src="image-input.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* The NotificationManager class implements five notification layers:
|
||||
* 1. In-app notification drawer (slide-out panel with grouped notifications)
|
||||
* 2. Tab title flash (alternating "(*) Codeman" when tab is hidden)
|
||||
* 2. Tab title flash (alternating "⚠️ (N) codeman:<host>" / "codeman:<host>" when tab is hidden; uses this.originalTitle so it tracks any per-host title)
|
||||
* 3. Browser Notification API (desktop push with auto-close after 8s)
|
||||
* 4. Web Push via service worker (OS-level notifications when tab is closed)
|
||||
* 5. Audio alerts (Web Audio API beep, user-opt-in)
|
||||
@@ -291,11 +291,11 @@ class NotificationManager {
|
||||
this.titleFlashInterval = setInterval(() => {
|
||||
this.titleFlashState = !this.titleFlashState;
|
||||
document.title = this.titleFlashState
|
||||
? `\u26A0\uFE0F (${this.unreadCount}) Codeman`
|
||||
? `\u26A0\uFE0F (${this.unreadCount}) ${this.originalTitle}`
|
||||
: this.originalTitle;
|
||||
}, TITLE_FLASH_INTERVAL_MS);
|
||||
// Set immediately
|
||||
document.title = `\u26A0\uFE0F (${this.unreadCount}) Codeman`;
|
||||
document.title = `\u26A0\uFE0F (${this.unreadCount}) ${this.originalTitle}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,7 +330,7 @@ class NotificationManager {
|
||||
if (now - this.lastBrowserNotifTime < BROWSER_NOTIF_RATE_LIMIT_MS) return;
|
||||
this.lastBrowserNotifTime = now;
|
||||
|
||||
const notif = new Notification(`Codeman: ${title}`, {
|
||||
const notif = new Notification(`${this.originalTitle}: ${title}`, {
|
||||
body,
|
||||
tag, // Groups same-tag notifications
|
||||
icon: '/favicon.ico',
|
||||
|
||||
@@ -1575,7 +1575,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
scrollback: 5000,
|
||||
scrollback: DEFAULT_SCROLLBACK,
|
||||
allowTransparency: true,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
@@ -912,11 +912,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
|
||||
if (!tabName) return;
|
||||
|
||||
// If a previous rename somehow leaked (shouldn't happen, but defends against
|
||||
// future code paths that throw before cleanup), abort it before starting fresh.
|
||||
if (this._activeRename) this._activeRename.cancel();
|
||||
|
||||
const currentName = this.getSessionName(session);
|
||||
const parsed = parseSessionPrefix(session.name);
|
||||
const originalContent = tabName.textContent;
|
||||
// Clear existing content to make room for the input element
|
||||
tabName.textContent = '';
|
||||
tabName.innerHTML = '';
|
||||
while (tabName.firstChild) tabName.removeChild(tabName.firstChild);
|
||||
|
||||
// If prefix detected, show it as non-editable label
|
||||
if (parsed) {
|
||||
@@ -937,17 +942,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const finishRename = async () => {
|
||||
const suffix = input.value.trim();
|
||||
let fullName;
|
||||
if (parsed) {
|
||||
fullName = parsed.prefix + (suffix ? ': ' + suffix : '');
|
||||
} else {
|
||||
fullName = suffix;
|
||||
let settled = false;
|
||||
const finishRename = async ({ commit }) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this._activeRename = null;
|
||||
|
||||
// Aborted (e.g. session was deleted mid-rename): just re-render so any
|
||||
// ghost DOM left behind is replaced with the canonical tab list.
|
||||
if (!commit) {
|
||||
this.renderSessionTabs();
|
||||
return;
|
||||
}
|
||||
|
||||
const suffix = input.value.trim();
|
||||
const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix;
|
||||
tabName.textContent = fullName || originalContent;
|
||||
|
||||
if (fullName !== session.name) {
|
||||
// Skip the API call if the session vanished between focus and blur.
|
||||
const stillExists = this.sessions.has(sessionId);
|
||||
if (stillExists && fullName !== session.name) {
|
||||
try {
|
||||
await fetch(`/api/sessions/${sessionId}/name`, {
|
||||
method: 'PUT',
|
||||
@@ -959,10 +973,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showToast('Failed to rename', 'error');
|
||||
}
|
||||
}
|
||||
// Re-render tabs to restore full tab structure
|
||||
this.renderSessionTabs();
|
||||
};
|
||||
|
||||
input.addEventListener('blur', finishRename);
|
||||
// Register only after the input is wired so a throw above can't strand state.
|
||||
this._activeRename = {
|
||||
sessionId,
|
||||
cancel: () => finishRename({ commit: false }),
|
||||
};
|
||||
|
||||
input.addEventListener('blur', () => finishRename({ commit: true }));
|
||||
input.addEventListener('keydown', (e) => {
|
||||
// Enter/Escape during IME composition belong to the IME (e.g. confirming
|
||||
// a Chinese pinyin candidate). keyCode 229 is the legacy signal for the
|
||||
// same condition on browsers that don't set isComposing reliably.
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
input.blur();
|
||||
|
||||
@@ -8591,3 +8591,23 @@ kbd {
|
||||
margin-top: 4px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Image drag-and-drop overlay */
|
||||
#terminalContainer.drag-active {
|
||||
outline: 2px dashed #4a9eff;
|
||||
outline-offset: -2px;
|
||||
position: relative;
|
||||
}
|
||||
#terminalContainer.drag-active::after {
|
||||
content: 'Drop image here';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(74, 158, 255, 0.08);
|
||||
color: #4a9eff;
|
||||
font-size: 1.2rem;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ self.addEventListener('push', (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, body, tag, sessionId, urgency, actions } = payload;
|
||||
const { title, hostTitle, body, tag, sessionId, urgency, actions } = payload;
|
||||
|
||||
const options = {
|
||||
body: body || '',
|
||||
@@ -126,8 +126,15 @@ self.addEventListener('push', (event) => {
|
||||
options.actions = actions;
|
||||
}
|
||||
|
||||
// Match the in-page Notification format: "codeman:<host>: <event title>".
|
||||
// hostTitle is sent by servers >= the hostname-aware push payload change;
|
||||
// older servers omit it and we fall back to the bare title.
|
||||
const displayTitle = hostTitle && title
|
||||
? `${hostTitle}: ${title}`
|
||||
: (title || hostTitle || 'Codeman');
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title || 'Codeman', options)
|
||||
self.registration.showNotification(displayTitle, options)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+114
-12
@@ -18,8 +18,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
initTerminal() {
|
||||
// Load scrollback setting from localStorage (default 5000)
|
||||
const scrollback = parseInt(localStorage.getItem('codeman-scrollback')) || DEFAULT_SCROLLBACK;
|
||||
// Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor
|
||||
// so users who picked up the previous (smaller) default get the new minimum on upgrade.
|
||||
const stored = parseInt(localStorage.getItem('codeman-scrollback'));
|
||||
const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK;
|
||||
|
||||
this.terminal = new Terminal({
|
||||
theme: {
|
||||
@@ -81,6 +83,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Let Alt+digit pass through to browser (tab switching)
|
||||
if (ev.altKey && ev.key >= '0' && ev.key <= '9') return false;
|
||||
|
||||
// Ctrl+V / Cmd+V: intercept before xterm sends ^V to PTY.
|
||||
// Route through our paste trap which handles both images and text.
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
|
||||
if (this.activeSessionId && this._handleImagePaste) {
|
||||
this._handleImagePaste();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Shift+Enter / Ctrl+Enter: insert newline for multi-line input.
|
||||
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
|
||||
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
|
||||
@@ -172,10 +183,36 @@ Object.assign(CodemanApp.prototype, {
|
||||
// but the 48KB/frame flush cap in flushPendingWrites() now prevents
|
||||
// oversized terminal.write() calls that triggered the stalls.
|
||||
// Disable with ?nowebgl URL param if GPU issues return.
|
||||
// Auto-fallback: _initWebGL installs a long-task watchdog that disables
|
||||
// WebGL sticky in localStorage after repeated GPU stalls (see app.js).
|
||||
// Force re-enable after sticky disable with ?webgl=force.
|
||||
// Lazy-loaded: script downloaded only on desktop (saves 244KB on mobile).
|
||||
this._webglAddon = null;
|
||||
const skipWebGL = MobileDetection.getDeviceType() !== 'desktop';
|
||||
if (!skipWebGL && !new URLSearchParams(location.search).has('nowebgl')) {
|
||||
const _params = new URLSearchParams(location.search);
|
||||
if (_params.get('webgl') === 'force') {
|
||||
try { localStorage.removeItem('codeman-webgl-disabled'); } catch {}
|
||||
}
|
||||
const _stickyDisabled = (() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('codeman-webgl-disabled');
|
||||
if (!raw) return false;
|
||||
const { at } = JSON.parse(raw);
|
||||
// Auto-expire after 7 days so we retry (driver may have been fixed)
|
||||
if (Date.now() - at > 7 * 24 * 60 * 60 * 1000) {
|
||||
localStorage.removeItem('codeman-webgl-disabled');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch { return false; }
|
||||
})();
|
||||
const skipWebGL =
|
||||
MobileDetection.getDeviceType() !== 'desktop' ||
|
||||
_params.has('nowebgl') ||
|
||||
_stickyDisabled;
|
||||
if (_stickyDisabled) {
|
||||
console.log('[CRASH-DIAG] WebGL sticky-disabled from prior stalls — DOM renderer in use. Re-enable: ?webgl=force');
|
||||
}
|
||||
if (!skipWebGL) {
|
||||
if (typeof WebglAddon !== 'undefined') {
|
||||
this._initWebGL();
|
||||
} else {
|
||||
@@ -339,6 +376,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Welcome message
|
||||
this.showWelcome();
|
||||
|
||||
// Image paste and drag-and-drop support
|
||||
this.initImageInput();
|
||||
|
||||
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
|
||||
this._chunkedWriteGen = 0;
|
||||
|
||||
@@ -1149,7 +1189,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
if (!this.writeFrameScheduled) {
|
||||
this.writeFrameScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this._safeYield(() => {
|
||||
// xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers
|
||||
// content between 2026h/2026l and renders atomically. No need for
|
||||
// client-side incomplete-block detection; just flush every frame.
|
||||
@@ -1174,7 +1214,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Trigger a normal flush
|
||||
if (!this.writeFrameScheduled) {
|
||||
this.writeFrameScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this._safeYield(() => {
|
||||
this.flushPendingWrites();
|
||||
this.writeFrameScheduled = false;
|
||||
});
|
||||
@@ -1262,7 +1302,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
deferred = true;
|
||||
if (!this.writeFrameScheduled) {
|
||||
this.writeFrameScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this._safeYield(() => {
|
||||
this.flushPendingWrites();
|
||||
this.writeFrameScheduled = false;
|
||||
});
|
||||
@@ -1334,9 +1374,70 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Schedule cb via THREE racing primitives so data-pacing makes progress
|
||||
* regardless of which scheduling primitive Chrome is throttling:
|
||||
* 1. requestAnimationFrame — primary, fires at compositor rate
|
||||
* (may be 0Hz when window is occluded / on backgrounded monitor).
|
||||
* 2. setTimeout(50) — fallback for occluded-but-visible windows
|
||||
* (clamped to 1Hz by Chrome's intensive wake-up throttling
|
||||
* after ~5 min of no user interaction).
|
||||
* 3. Worker postMessage — bypasses intensive throttling entirely;
|
||||
* Workers are not subject to background-tab / idle-tab throttling
|
||||
* (the React Scheduler trick).
|
||||
* Whichever fires first wins; the others are no-ops thanks to the
|
||||
* `done` guard. Without all three, chunkedTerminalWrite and the deferred
|
||||
* path of flushPendingWrites stall indefinitely when the substrate is
|
||||
* degraded (visible-but-occluded window, OR idle-throttled tab, OR
|
||||
* background tab on a different monitor).
|
||||
*/
|
||||
_safeYield(cb) {
|
||||
let done = false;
|
||||
const wrapped = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
cb();
|
||||
};
|
||||
requestAnimationFrame(wrapped);
|
||||
setTimeout(wrapped, 50);
|
||||
this._workerYield(wrapped);
|
||||
},
|
||||
|
||||
/**
|
||||
* Lazy-init a tiny "tick" worker whose only job is to postMessage back to
|
||||
* us as fast as possible, escaping main-thread throttling. The worker's
|
||||
* setTimeout(0) is not subject to Chrome's intensive wake-up throttling
|
||||
* even when the parent tab is idle.
|
||||
*/
|
||||
_workerYield(cb) {
|
||||
try {
|
||||
if (this._yieldWorker === undefined) {
|
||||
// First call: build the worker (or mark unavailable). Each
|
||||
// postMessage in produces exactly one postMessage out — we count on
|
||||
// FIFO 1:1 to drain queue entries.
|
||||
const src = "onmessage=()=>setTimeout(()=>postMessage(0),0);";
|
||||
const blob = new Blob([src], { type: 'application/javascript' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
this._yieldWorker = new Worker(url);
|
||||
URL.revokeObjectURL(url);
|
||||
this._yieldQueue = [];
|
||||
this._yieldWorker.onmessage = () => {
|
||||
const fn = this._yieldQueue.shift();
|
||||
if (fn) fn();
|
||||
};
|
||||
}
|
||||
if (!this._yieldWorker) return;
|
||||
this._yieldQueue.push(cb);
|
||||
this._yieldWorker.postMessage(0);
|
||||
} catch {
|
||||
this._yieldWorker = null; // mark unavailable, future calls skip
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Write large buffer to terminal in chunks to avoid UI jank.
|
||||
* Uses requestAnimationFrame to spread work across frames.
|
||||
* Uses _safeYield to spread work across frames; falls back to setTimeout
|
||||
* and a tick-Worker so progress continues on occluded / idle-throttled tabs.
|
||||
* @param {string} buffer - The full terminal buffer to write
|
||||
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
|
||||
* @returns {Promise<void>} - Resolves when all chunks written
|
||||
@@ -1395,7 +1496,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`
|
||||
);
|
||||
// Wait one more frame for xterm to finish rendering before resolving
|
||||
requestAnimationFrame(finish);
|
||||
this._safeYield(finish);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1410,12 +1511,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
);
|
||||
offset += chunkSize;
|
||||
|
||||
// Schedule next chunk on next frame
|
||||
requestAnimationFrame(writeChunk);
|
||||
// Schedule next chunk; rAF if possible, else setTimeout/Worker
|
||||
// fallback so progress doesn't stall on occluded/unfocused windows.
|
||||
this._safeYield(writeChunk);
|
||||
};
|
||||
|
||||
// Start writing
|
||||
requestAnimationFrame(writeChunk);
|
||||
this._safeYield(writeChunk);
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { join, dirname, extname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { execFile } from 'node:child_process';
|
||||
@@ -66,48 +66,60 @@ const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
|
||||
|
||||
/**
|
||||
* Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer.
|
||||
* Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA, CSI n;m H = CUP)
|
||||
* to animate the spinner and update the status bar. During long thinking phases, these frames
|
||||
* accumulate to 500KB+ of repeated overwrites to the same rows. When the buffer is tailed,
|
||||
* only spinner frames are returned, making the terminal appear empty.
|
||||
* Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA) to animate
|
||||
* the spinner and update the status bar. During long thinking phases, these frames
|
||||
* accumulate to 500KB+ of repeated overwrites to the same rows.
|
||||
*
|
||||
* Strategy: find where absolute-positioned redraws begin (first VPA sequence), then keep
|
||||
* only the last ~4KB of redraw frames (the final visual state) and discard the rest.
|
||||
* Strategy: detect "redraw clusters" — dense runs of VPA escapes where each is within
|
||||
* FRAME_GAP bytes of the previous (i.e. continuous rerendering of the same UI region).
|
||||
* Collapse each big cluster down to just the bytes from its last VPA onwards (the final
|
||||
* frame). Content *between* clusters (Claude's streamed response text) is preserved.
|
||||
*
|
||||
* Without clustering, a single first-VPA-finds-all approach would discard the entire
|
||||
* conversation after Claude's first render — losing 100KB+ of legitimate scrollback.
|
||||
*/
|
||||
function stripInkRedrawBloat(buffer: string): string {
|
||||
// Find where Ink's absolute-positioned redraws start (first CSI n d = VPA)
|
||||
export function stripInkRedrawBloat(buffer: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const firstVPA = buffer.search(/\x1b\[\d+d/);
|
||||
if (firstVPA === -1) return buffer; // No Ink redraws
|
||||
|
||||
const contentPart = buffer.slice(0, firstVPA);
|
||||
const redrawPart = buffer.slice(firstVPA);
|
||||
|
||||
// If the redraw section is small (<16KB), not worth stripping
|
||||
if (redrawPart.length < 16384) return buffer;
|
||||
|
||||
// Find the last complete Ink frame by searching for where the VPA row
|
||||
// number drops (cursor jumps back to viewport top for a new render cycle).
|
||||
// Search the last 64KB — a single Ink frame with response content can be
|
||||
// 10-20KB, so 4KB was too small and caused partial frames (blank gap).
|
||||
const searchLen = Math.min(redrawPart.length, 65536);
|
||||
const searchWindow = redrawPart.slice(-searchLen);
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const vpaRe = /\x1b\[(\d+)d/g;
|
||||
let lastFrameStart = 0;
|
||||
let prevRow = -1;
|
||||
let match;
|
||||
while ((match = vpaRe.exec(searchWindow)) !== null) {
|
||||
const row = parseInt(match[1], 10);
|
||||
// Row number dropped significantly — Ink started a new frame
|
||||
if (prevRow > 0 && row < prevRow - 5) {
|
||||
lastFrameStart = match.index;
|
||||
}
|
||||
prevRow = row;
|
||||
const vpaRe = /\x1b\[\d+d/g;
|
||||
const positions: number[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = vpaRe.exec(buffer)) !== null) {
|
||||
positions.push(m.index);
|
||||
}
|
||||
if (positions.length < 10) return buffer; // Too few VPAs to be bloat
|
||||
|
||||
return contentPart + searchWindow.slice(lastFrameStart);
|
||||
// Group consecutive VPAs into clusters separated by gaps > FRAME_GAP.
|
||||
// Within a cluster, VPAs are close together (continuous rerenders).
|
||||
// Between clusters, real terminal output (response text) lives.
|
||||
const FRAME_GAP = 8 * 1024; // 8KB — one Ink frame is typically 1-4KB
|
||||
const MIN_BLOAT_SIZE = 32 * 1024; // Only collapse clusters spanning >= 32KB
|
||||
|
||||
const clusters: { start: number; end: number }[] = [];
|
||||
let cs = positions[0];
|
||||
let ce = positions[0];
|
||||
for (let i = 1; i < positions.length; i++) {
|
||||
if (positions[i] - ce <= FRAME_GAP) {
|
||||
ce = positions[i];
|
||||
} else {
|
||||
clusters.push({ start: cs, end: ce });
|
||||
cs = positions[i];
|
||||
ce = positions[i];
|
||||
}
|
||||
}
|
||||
clusters.push({ start: cs, end: ce });
|
||||
|
||||
// For each big cluster, replace [start..end] with the bytes from `end` onwards
|
||||
// (which contains the last frame's content up to where the next cluster, or
|
||||
// post-cluster content, begins).
|
||||
const parts: string[] = [];
|
||||
let cursor = 0;
|
||||
for (const cl of clusters) {
|
||||
if (cl.end - cl.start < MIN_BLOAT_SIZE) continue;
|
||||
parts.push(buffer.slice(cursor, cl.start));
|
||||
cursor = cl.end;
|
||||
}
|
||||
parts.push(buffer.slice(cursor));
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(
|
||||
@@ -1425,4 +1437,109 @@ export function registerSessionRoutes(
|
||||
|
||||
return { sessions: results.slice(0, 50) };
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Paste Image (clipboard / drag-drop upload)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const MAX_PASTE_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
|
||||
|
||||
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
const contentType = req.headers['content-type'] ?? '';
|
||||
if (!contentType.includes('multipart/form-data')) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
||||
}
|
||||
|
||||
// Parse multipart boundary
|
||||
const boundaryMatch = contentType.match(/boundary=(.+?)(?:;|$)/);
|
||||
if (!boundaryMatch) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing boundary');
|
||||
}
|
||||
|
||||
// Collect raw body with size limit
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
for await (const chunk of req.raw) {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_PASTE_IMAGE_SIZE) {
|
||||
reply.code(413);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File too large (max 10MB)');
|
||||
}
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
const body = Buffer.concat(chunks);
|
||||
|
||||
// Extract image from multipart body
|
||||
const boundary = '--' + boundaryMatch[1];
|
||||
const boundaryBuf = Buffer.from(boundary);
|
||||
const parts: { headers: string; data: Buffer }[] = [];
|
||||
let pos = 0;
|
||||
|
||||
while (pos < body.length) {
|
||||
const start = body.indexOf(boundaryBuf, pos);
|
||||
if (start === -1) break;
|
||||
const afterBoundary = start + boundaryBuf.length;
|
||||
if (body[afterBoundary] === 0x2d && body[afterBoundary + 1] === 0x2d) break;
|
||||
const headerStart = afterBoundary + 2;
|
||||
const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'), headerStart);
|
||||
if (headerEnd === -1) break;
|
||||
const headers = body.subarray(headerStart, headerEnd).toString();
|
||||
const dataStart = headerEnd + 4;
|
||||
const nextBoundary = body.indexOf(boundaryBuf, dataStart);
|
||||
const dataEnd = nextBoundary === -1 ? body.length : nextBoundary - 2;
|
||||
parts.push({ headers, data: body.subarray(dataStart, dataEnd) });
|
||||
pos = nextBoundary === -1 ? body.length : nextBoundary;
|
||||
}
|
||||
|
||||
const imagePart = parts.find((p) => p.headers.includes('name="image"'));
|
||||
if (!imagePart || imagePart.data.length === 0) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No image uploaded');
|
||||
}
|
||||
|
||||
// Determine extension from filename or Content-Type
|
||||
let ext = '.png';
|
||||
const filenameMatch = imagePart.headers.match(/filename="(.+?)"/);
|
||||
if (filenameMatch) {
|
||||
const origExt = extname(filenameMatch[1]).toLowerCase();
|
||||
if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt;
|
||||
}
|
||||
const ctMatch = imagePart.headers.match(/Content-Type:\s*image\/(png|jpeg|jpg|webp|gif|bmp)/i);
|
||||
if (ctMatch) {
|
||||
const map: Record<string, string> = {
|
||||
png: '.png',
|
||||
jpeg: '.jpg',
|
||||
jpg: '.jpg',
|
||||
webp: '.webp',
|
||||
gif: '.gif',
|
||||
bmp: '.bmp',
|
||||
};
|
||||
ext = map[ctMatch[1].toLowerCase()] ?? ext;
|
||||
}
|
||||
|
||||
if (!ALLOWED_IMAGE_EXTS.has(ext)) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`Unsupported image type: ${ext}. Allowed: ${[...ALLOWED_IMAGE_EXTS].join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
// Save to {workingDir}/.claude-images/
|
||||
const imageDir = join(session.workingDir, '.claude-images');
|
||||
if (!existsSync(imageDir)) {
|
||||
mkdirSync(imageDir, { recursive: true });
|
||||
}
|
||||
const filename = `paste-${Date.now()}${ext}`;
|
||||
const filepath = join(imageDir, filename);
|
||||
await fs.writeFile(filepath, imagePart.data);
|
||||
|
||||
return { success: true, path: filepath, filename };
|
||||
});
|
||||
}
|
||||
|
||||
+71
-9
@@ -34,10 +34,10 @@ 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';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { homedir } from 'node:os';
|
||||
import { homedir, hostname as getHostname } from 'node:os';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session, type BackgroundTask } from '../session.js';
|
||||
import type { ClaudeMode, SessionState } from '../types.js';
|
||||
@@ -119,6 +119,15 @@ import {
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Bounded, predictable shape for SSE client identifiers: alphanumerics, `_`, `-`.
|
||||
// Length range covers crypto.randomUUID() (36 chars) plus any short stable IDs,
|
||||
// while capping growth of `sseClientsById` and blocking pathological inputs.
|
||||
const SSE_CLIENT_ID_RE = /^[A-Za-z0-9_-]{8,64}$/;
|
||||
|
||||
function escapeHtmlText(value: string): string {
|
||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
}
|
||||
|
||||
import {
|
||||
SESSIONS_LIST_CACHE_TTL,
|
||||
SCHEDULED_CLEANUP_INTERVAL,
|
||||
@@ -226,12 +235,18 @@ export class WebServer extends EventEmitter {
|
||||
teamRemoved: (config: unknown) => void;
|
||||
taskUpdated: (data: unknown) => void;
|
||||
} | null = null;
|
||||
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) {
|
||||
private readonly titleHostname: string;
|
||||
private readonly windowTitle: string;
|
||||
private readonly indexHtmlTemplate: string;
|
||||
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false, titleHostname?: string) {
|
||||
super();
|
||||
this.setMaxListeners(0);
|
||||
this.port = port;
|
||||
this.https = https;
|
||||
this.testMode = testMode;
|
||||
this.titleHostname = titleHostname || getHostname();
|
||||
this.windowTitle = `codeman:${this.titleHostname}`;
|
||||
this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8');
|
||||
|
||||
if (https) {
|
||||
const { key, cert } = getOrCreateSelfSignedCert();
|
||||
@@ -526,6 +541,12 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Security headers + CORS
|
||||
registerSecurityHeaders(this.app, this.https);
|
||||
this.app.get('/', async (_req, reply) => {
|
||||
return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml());
|
||||
});
|
||||
this.app.get('/index.html', async (_req, reply) => {
|
||||
return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml());
|
||||
});
|
||||
// Service worker must never be cached — browsers check for SW updates on navigation
|
||||
this.app.get('/sw.js', async (_req, reply) => {
|
||||
return reply
|
||||
@@ -563,9 +584,11 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
// Parse optional session subscription filter from query parameter.
|
||||
// /api/events?sessions=id1,id2 — client only receives events for those sessions.
|
||||
// /api/events (no param) — client receives all events (backwards-compatible).
|
||||
const query = req.query as { sessions?: string };
|
||||
// /api/events?sessions=id1,id2 — client only receives session:terminal
|
||||
// events for those sessions (other events broadcast to all clients).
|
||||
// /api/events?clientId=<uuid> — enables live filter updates via
|
||||
// POST /api/events/subscribe without reconnecting.
|
||||
const query = req.query as { sessions?: string; clientId?: string };
|
||||
let sessionFilter: Set<string> | null = null;
|
||||
if (query.sessions) {
|
||||
const ids = query.sessions
|
||||
@@ -576,6 +599,8 @@ export class WebServer extends EventEmitter {
|
||||
sessionFilter = new Set(ids);
|
||||
}
|
||||
}
|
||||
const clientId =
|
||||
typeof query.clientId === 'string' && SSE_CLIENT_ID_RE.test(query.clientId) ? query.clientId : undefined;
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
@@ -587,7 +612,7 @@ export class WebServer extends EventEmitter {
|
||||
// Track tunnel clients — cloudflared proxies locally so req.ip is always
|
||||
// 127.0.0.1; detect tunnel traffic via Cf-Connecting-Ip header instead.
|
||||
const isRemote = !!req.headers['cf-connecting-ip'];
|
||||
this.sse.addClient(reply, sessionFilter, isRemote);
|
||||
this.sse.addClient(reply, sessionFilter, isRemote, clientId);
|
||||
|
||||
// Send initial state
|
||||
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
|
||||
@@ -602,6 +627,22 @@ export class WebServer extends EventEmitter {
|
||||
});
|
||||
});
|
||||
|
||||
// Live subscription update — change a connected client's session filter
|
||||
// without forcing an SSE reconnect. Body: { clientId, sessions: string[] | null }
|
||||
// Empty/null sessions array = remove filter (receive all session:terminal events).
|
||||
this.app.post('/api/events/subscribe', (req, reply) => {
|
||||
const body = (req.body || {}) as { clientId?: string; sessions?: string[] | null };
|
||||
if (typeof body.clientId !== 'string' || !SSE_CLIENT_ID_RE.test(body.clientId)) {
|
||||
reply.code(400).send({ error: 'clientId required' });
|
||||
return;
|
||||
}
|
||||
const sessions = Array.isArray(body.sessions)
|
||||
? body.sessions.filter((s) => typeof s === 'string' && s.length > 0 && s.length <= 128).slice(0, 64)
|
||||
: null;
|
||||
const updated = this.sse.updateClientFilter(body.clientId, sessions);
|
||||
reply.code(updated ? 204 : 404).send();
|
||||
});
|
||||
|
||||
// Global error handler for structured errors thrown by findSessionOrFail
|
||||
this.app.setErrorHandler((error, _req, reply) => {
|
||||
const statusCode = (error as { statusCode?: number }).statusCode ?? 500;
|
||||
@@ -910,6 +951,15 @@ export class WebServer extends EventEmitter {
|
||||
fileStreamManager.closeSessionStreams(sessionId);
|
||||
// Stop watching for images in this session's directory
|
||||
imageWatcher.unwatchSession(sessionId);
|
||||
// Clean up pasted images directory for this session
|
||||
if (killMux && session.workingDir) {
|
||||
const pasteImageDir = join(session.workingDir, '.claude-images');
|
||||
try {
|
||||
rmSync(pasteImageDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
await session.stop(killMux);
|
||||
this.sessions.delete(sessionId);
|
||||
// Only remove from state.json if we're also killing the mux session.
|
||||
@@ -922,6 +972,13 @@ export class WebServer extends EventEmitter {
|
||||
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
||||
}
|
||||
|
||||
private renderIndexHtml(): string {
|
||||
return this.indexHtmlTemplate.replace(
|
||||
'<title>Codeman</title>',
|
||||
`<title>${escapeHtmlText(this.windowTitle)}</title>`
|
||||
);
|
||||
}
|
||||
|
||||
private async setupSessionListeners(session: Session): Promise<void> {
|
||||
// Create run summary tracker for this session
|
||||
const summaryTracker = new RunSummaryTracker(session.id, session.name);
|
||||
@@ -1414,6 +1471,10 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
const payload = JSON.stringify({
|
||||
title: template.title,
|
||||
// Hostname-aware prefix so OS-level notifications from multiple Codeman
|
||||
// instances (laptop / dev box / NAS) are unambiguous in the system tray.
|
||||
// Mirrors the in-page Notification format in notification-manager.js.
|
||||
hostTitle: this.windowTitle,
|
||||
body,
|
||||
tag: `codeman-${event}-${sessionId}`,
|
||||
sessionId,
|
||||
@@ -1970,9 +2031,10 @@ export class WebServer extends EventEmitter {
|
||||
export async function startWebServer(
|
||||
port: number = 3000,
|
||||
https: boolean = false,
|
||||
testMode: boolean = false
|
||||
testMode: boolean = false,
|
||||
titleHostname?: string
|
||||
): Promise<WebServer> {
|
||||
const server = new WebServer(port, https, testMode);
|
||||
const server = new WebServer(port, https, testMode, titleHostname);
|
||||
await server.start();
|
||||
return server;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ export class SseStreamManager {
|
||||
* or `null` meaning "receive all events" (backwards-compatible default).
|
||||
*/
|
||||
private sseClients: Map<FastifyReply, Set<string> | null> = new Map();
|
||||
/** Optional client-supplied IDs → reply, for live filter updates without reconnecting */
|
||||
private sseClientsById: Map<string, FastifyReply> = 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 */
|
||||
@@ -103,17 +105,43 @@ export class SseStreamManager {
|
||||
this._isTunnelActive = active;
|
||||
}
|
||||
|
||||
addClient(reply: FastifyReply, sessionFilter: Set<string> | null, isRemote: boolean): void {
|
||||
addClient(reply: FastifyReply, sessionFilter: Set<string> | null, isRemote: boolean, clientId?: string): void {
|
||||
this.sseClients.set(reply, sessionFilter);
|
||||
if (isRemote) {
|
||||
this.remoteSseClients.add(reply);
|
||||
}
|
||||
if (clientId) {
|
||||
// If a previous reply registered the same id (reconnect), drop the old one.
|
||||
const prev = this.sseClientsById.get(clientId);
|
||||
if (prev && prev !== reply) {
|
||||
this.sseClients.delete(prev);
|
||||
this.remoteSseClients.delete(prev);
|
||||
this.backpressuredClients.delete(prev);
|
||||
}
|
||||
this.sseClientsById.set(clientId, reply);
|
||||
}
|
||||
}
|
||||
|
||||
removeClient(reply: FastifyReply): void {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.delete(reply);
|
||||
this.backpressuredClients.delete(reply);
|
||||
// Clear any clientId mappings pointing at this reply
|
||||
for (const [id, r] of this.sseClientsById) {
|
||||
if (r === reply) this.sseClientsById.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing client's session subscription filter without forcing
|
||||
* an SSE reconnect. Returns true if the client was found and updated.
|
||||
*/
|
||||
updateClientFilter(clientId: string, sessions: string[] | null): boolean {
|
||||
const reply = this.sseClientsById.get(clientId);
|
||||
if (!reply || !this.sseClients.has(reply)) return false;
|
||||
const filter = sessions && sessions.length > 0 ? new Set(sessions) : null;
|
||||
this.sseClients.set(reply, filter);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Send a single SSE event to a specific client. */
|
||||
@@ -188,35 +216,18 @@ export class SseStreamManager {
|
||||
console.error(`[Server] Failed to serialize SSE event "${event}":`, err);
|
||||
return;
|
||||
}
|
||||
// Extract sessionId from event data for subscription filtering.
|
||||
const eventSessionId = this.extractSessionId(event, data);
|
||||
|
||||
for (const [client, filter] of this.sseClients) {
|
||||
// No filter (null) = receive everything. Otherwise, skip if event is
|
||||
// session-scoped and the session isn't in the client's subscription set.
|
||||
if (filter && eventSessionId && !filter.has(eventSessionId)) continue;
|
||||
// Subscription filtering is intentionally NOT applied here. The
|
||||
// `?sessions=` filter is intended to suppress only the high-volume
|
||||
// terminal stream — lifecycle/metadata events (session:created,
|
||||
// session:updated, ralph:*, hook:*, etc.) are needed for correct UI
|
||||
// state across all sessions even when the client subscribes to a single
|
||||
// active session's terminal output. Terminal events bypass this method
|
||||
// entirely (see flushSessionTerminalBatch — it applies the filter).
|
||||
for (const [client] of this.sseClients) {
|
||||
this.sendSSEPreformatted(client, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the session ID from an event's data payload for subscription filtering.
|
||||
* Returns the sessionId string if the event is session-scoped, or null for global events.
|
||||
*/
|
||||
private extractSessionId(event: string, data: unknown): string | null {
|
||||
if (data == null || typeof data !== 'object') return null;
|
||||
const record = data as Record<string, unknown>;
|
||||
|
||||
// Most session-scoped events use `sessionId`
|
||||
if (typeof record.sessionId === 'string') return record.sessionId;
|
||||
|
||||
// Session lifecycle events (session:*) use `id` from the session state object
|
||||
if (typeof record.id === 'string' && event.startsWith('session:')) return record.id;
|
||||
|
||||
// No session ID found — treat as global event (sent to all clients)
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========== Terminal Data Batching ==========
|
||||
|
||||
// Batch terminal data for better performance (60fps)
|
||||
|
||||
@@ -49,8 +49,10 @@ async function waitForElement(selector: string, timeout = 10000): Promise<boolea
|
||||
try {
|
||||
const count = browserJson<{ count: number }>(`get count "${selector}"`);
|
||||
if (count.count > 0) return true;
|
||||
} catch { /* retry */ }
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
} catch {
|
||||
/* retry */
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -74,7 +76,9 @@ function isVisible(selector: string): boolean {
|
||||
function closeBrowser() {
|
||||
try {
|
||||
browser('close');
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
describe('File Link Click Tests', () => {
|
||||
@@ -95,14 +99,14 @@ describe('File Link Click Tests', () => {
|
||||
|
||||
server = new WebServer(TEST_PORT, false, true);
|
||||
await server.start();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
// Test if browser is available
|
||||
try {
|
||||
browser(`open ${baseUrl}`);
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const title = browserJson<{ title: string }>('get title');
|
||||
browserAvailable = title.title === 'Codeman';
|
||||
browserAvailable = title.title.startsWith('codeman:');
|
||||
} catch (e) {
|
||||
console.warn('Browser not available, skipping browser tests:', (e as Error).message);
|
||||
browserAvailable = false;
|
||||
@@ -114,14 +118,18 @@ describe('File Link Click Tests', () => {
|
||||
for (const sessionId of createdSessions) {
|
||||
try {
|
||||
await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
await server.stop();
|
||||
|
||||
// Cleanup test directory
|
||||
try {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('should create shell session and display terminal output', async () => {
|
||||
@@ -142,7 +150,7 @@ describe('File Link Click Tests', () => {
|
||||
createdSessions.push(data.session.id);
|
||||
|
||||
// Wait for session to appear in UI
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
|
||||
// Check that terminal is visible
|
||||
const terminalExists = await waitForElement('.xterm-screen', 5000);
|
||||
@@ -167,7 +175,7 @@ describe('File Link Click Tests', () => {
|
||||
body: JSON.stringify({ input: command + '\r' }),
|
||||
});
|
||||
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
|
||||
// Check if xterm contains the file path
|
||||
// The xterm link provider should detect "tail -f /path/to/file" pattern
|
||||
@@ -204,7 +212,7 @@ describe('File Link Click Tests', () => {
|
||||
// Click somewhere in the terminal where the tail -f line should be
|
||||
// This is approximate - the link detection works on hover
|
||||
browser('click ".xterm-screen"');
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
} catch (e) {
|
||||
console.log('Click failed:', e);
|
||||
}
|
||||
@@ -243,10 +251,10 @@ describe('File Link Click Tests', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: `echo "${pattern}"\r` }),
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
// Verify patterns appear in terminal
|
||||
const terminalText = getText('.xterm-screen');
|
||||
@@ -255,11 +263,12 @@ describe('File Link Click Tests', () => {
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('should match file paths with various command patterns', () => {
|
||||
it('should match file paths with various command patterns', () => {
|
||||
// Unit test for pattern matching logic - runs without browser
|
||||
// Pattern matches: tail -f /path, grep pattern /path, cat -n /path
|
||||
const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
||||
const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||||
const extPattern =
|
||||
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||||
|
||||
// Test cmdPattern
|
||||
@@ -309,13 +318,14 @@ it('should match file paths with various command patterns', () => {
|
||||
});
|
||||
|
||||
it('should NOT match invalid or unsafe paths', () => {
|
||||
const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||||
const extPattern =
|
||||
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||||
|
||||
const invalidCases = [
|
||||
'This is just text without paths',
|
||||
'./relative/path.log', // relative path
|
||||
'C:\\Windows\\path.log', // windows path
|
||||
'/usr/bin/something.log', // /usr not in allowed prefixes
|
||||
'./relative/path.log', // relative path
|
||||
'C:\\Windows\\path.log', // windows path
|
||||
'/usr/bin/something.log', // /usr not in allowed prefixes
|
||||
];
|
||||
|
||||
for (const line of invalidCases) {
|
||||
@@ -337,7 +347,7 @@ it('should match file paths with various command patterns', () => {
|
||||
});
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(true);
|
||||
sessionId = data.sessionId; // quick-start returns sessionId directly
|
||||
sessionId = data.sessionId; // quick-start returns sessionId directly
|
||||
createdSessions.push(sessionId);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Inline rename input tests.
|
||||
*
|
||||
* Covers the three fixes shipped after the audit of #81:
|
||||
* 1. CJK composition guard — Enter/Escape during IME composition belong to
|
||||
* the IME and must not commit/cancel the rename.
|
||||
* 2. Ghost tab cleanup — when a session is deleted while its tab is being
|
||||
* renamed, _cleanupSessionData() must cancel the rename so the inline
|
||||
* <input> doesn't ghost on screen.
|
||||
* 3. Settle-once — cancel()/blur convergence is idempotent and reliably
|
||||
* clears _activeRename, even on repeated invocation.
|
||||
*
|
||||
* Strategy: stub a synthetic .tab-name node and a fake session entry, then
|
||||
* drive the rename function directly via page.evaluate(). No real PTY/tmux.
|
||||
*
|
||||
* Port: 3164 (per MEMORY.md, ports 3150+ for tests)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3164;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('Inline rename input', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true); // testMode = true
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
// Wait for app.js to expose window.app and finish constructor init.
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof (window as { app?: unknown }).app !== 'undefined' &&
|
||||
!!(window as { app?: { sessions?: Map<string, unknown> } }).app?.sessions
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
// Reset state between tests so each starts from a clean slate.
|
||||
async function resetState(): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const app = (
|
||||
window as unknown as { app: { _activeRename: { cancel: () => void } | null; sessions: Map<string, unknown> } }
|
||||
).app;
|
||||
if (app._activeRename) app._activeRename.cancel();
|
||||
app.sessions.clear();
|
||||
document.querySelectorAll('[data-test-tab]').forEach((n) => n.remove());
|
||||
});
|
||||
// Allow any cancel-triggered renderSessionTabs to settle.
|
||||
await page.waitForTimeout(20);
|
||||
}
|
||||
|
||||
// Helper: stub a session + tab-name DOM node, then start rename.
|
||||
// Returns whether the rename input was successfully created.
|
||||
async function startRename(sessionId: string, name: string): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
({ id, name }) => {
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
sessions: Map<string, { id: string; name: string }>;
|
||||
startInlineRename: (id: string) => void;
|
||||
};
|
||||
}
|
||||
).app;
|
||||
app.sessions.set(id, { id, name });
|
||||
const wrap = document.createElement('div');
|
||||
wrap.setAttribute('data-test-tab', '1');
|
||||
const tabName = document.createElement('span');
|
||||
tabName.className = 'tab-name';
|
||||
tabName.setAttribute('data-session-id', id);
|
||||
tabName.textContent = name;
|
||||
wrap.appendChild(tabName);
|
||||
document.body.appendChild(wrap);
|
||||
app.startInlineRename(id);
|
||||
return !!tabName.querySelector('input.tab-rename-input');
|
||||
},
|
||||
{ id: sessionId, name }
|
||||
);
|
||||
}
|
||||
|
||||
it('CJK guard: Enter with isComposing=true does not commit', async () => {
|
||||
await resetState();
|
||||
expect(await startRename('cjk-isc', 'OldName')).toBe(true);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const app = (window as unknown as { app: { _activeRename: unknown } }).app;
|
||||
const input = document.querySelector('input.tab-rename-input') as HTMLInputElement;
|
||||
input.value = 'partial-pinyin';
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', isComposing: true, bubbles: true }));
|
||||
return {
|
||||
inputStillInDom: document.body.contains(input),
|
||||
renameStillActive: !!app._activeRename,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.inputStillInDom).toBe(true);
|
||||
expect(result.renameStillActive).toBe(true);
|
||||
});
|
||||
|
||||
it('CJK guard: Enter with legacy keyCode 229 does not commit', async () => {
|
||||
await resetState();
|
||||
expect(await startRename('cjk-229', 'OldName')).toBe(true);
|
||||
|
||||
const renameStillActive = await page.evaluate(() => {
|
||||
const app = (window as unknown as { app: { _activeRename: unknown } }).app;
|
||||
const input = document.querySelector('input.tab-rename-input') as HTMLInputElement;
|
||||
// Some Safari/Edge versions report keyCode 229 with isComposing=false on the
|
||||
// Enter that triggers compositionend — the legacy guard catches that case.
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 229, bubbles: true }));
|
||||
return !!app._activeRename;
|
||||
});
|
||||
|
||||
expect(renameStillActive).toBe(true);
|
||||
});
|
||||
|
||||
it('CJK guard: regular Enter (no IME) DOES commit', async () => {
|
||||
await resetState();
|
||||
expect(await startRename('regular-enter', 'OldName')).toBe(true);
|
||||
|
||||
// Stub fetch so the commit doesn't hit the real API.
|
||||
const result = await page.evaluate(async () => {
|
||||
const app = (window as unknown as { app: { _activeRename: unknown } }).app;
|
||||
let fetchUrl: string | null = null;
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = (async (input: RequestInfo | URL) => {
|
||||
fetchUrl = String(input);
|
||||
return new Response('{"success":true}', { status: 200 });
|
||||
}) as typeof window.fetch;
|
||||
|
||||
const inputEl = document.querySelector('input.tab-rename-input') as HTMLInputElement;
|
||||
inputEl.value = 'NewName';
|
||||
inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
// Enter calls input.blur() which fires the async finishRename. Wait for it.
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
window.fetch = origFetch;
|
||||
return { fetchUrl, renameActive: !!app._activeRename };
|
||||
});
|
||||
|
||||
expect(result.fetchUrl).toContain('/api/sessions/regular-enter/name');
|
||||
expect(result.renameActive).toBe(false);
|
||||
});
|
||||
|
||||
it('Ghost tab: _cleanupSessionData cancels rename for the deleted session', async () => {
|
||||
await resetState();
|
||||
expect(await startRename('ghost-id', 'OldName')).toBe(true);
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
_activeRename: { sessionId: string } | null;
|
||||
sessions: Map<string, unknown>;
|
||||
_cleanupSessionData: (id: string) => void;
|
||||
};
|
||||
}
|
||||
).app;
|
||||
|
||||
let fetchFired = false;
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = (async (input: RequestInfo | URL) => {
|
||||
if (String(input).includes('/api/sessions/ghost-id/name')) fetchFired = true;
|
||||
return new Response('{}', { status: 200 });
|
||||
}) as typeof window.fetch;
|
||||
|
||||
const matchedBefore = app._activeRename?.sessionId === 'ghost-id';
|
||||
app._cleanupSessionData('ghost-id');
|
||||
// Cancel triggers async renderSessionTabs; allow it to settle.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
window.fetch = origFetch;
|
||||
return {
|
||||
matchedBefore,
|
||||
renameActiveAfter: !!app._activeRename,
|
||||
sessionGone: !app.sessions.has('ghost-id'),
|
||||
fetchFired,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.matchedBefore).toBe(true);
|
||||
expect(result.renameActiveAfter).toBe(false);
|
||||
expect(result.sessionGone).toBe(true);
|
||||
// Cancel path skips the API call — deleting a session shouldn't trigger a stale rename PUT.
|
||||
expect(result.fetchFired).toBe(false);
|
||||
});
|
||||
|
||||
it('Ghost tab: _cleanupSessionData for a DIFFERENT session does NOT cancel rename', async () => {
|
||||
await resetState();
|
||||
expect(await startRename('keep-rename', 'OldName')).toBe(true);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
_activeRename: unknown;
|
||||
sessions: Map<string, { id: string; name: string }>;
|
||||
_cleanupSessionData: (id: string) => void;
|
||||
};
|
||||
}
|
||||
).app;
|
||||
// Add an unrelated session and delete it — the rename for keep-rename must survive.
|
||||
app.sessions.set('unrelated', { id: 'unrelated', name: 'X' });
|
||||
app._cleanupSessionData('unrelated');
|
||||
return { renameStillActive: !!app._activeRename };
|
||||
});
|
||||
|
||||
expect(result.renameStillActive).toBe(true);
|
||||
});
|
||||
|
||||
it('Settle-once: cancel() is idempotent and clears _activeRename', async () => {
|
||||
await resetState();
|
||||
expect(await startRename('idempotent-id', 'OldName')).toBe(true);
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app;
|
||||
const cancelFn = app._activeRename!.cancel;
|
||||
cancelFn();
|
||||
const afterFirst = app._activeRename;
|
||||
let threw = false;
|
||||
try {
|
||||
cancelFn();
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
// Allow any async re-renders to settle.
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const afterSecond = app._activeRename;
|
||||
return { afterFirstNull: afterFirst === null, afterSecondNull: afterSecond === null, threw };
|
||||
});
|
||||
|
||||
expect(result.afterFirstNull).toBe(true);
|
||||
expect(result.afterSecondNull).toBe(true);
|
||||
expect(result.threw).toBe(false);
|
||||
});
|
||||
|
||||
it('Re-entry: starting rename while one is active aborts the previous one', async () => {
|
||||
await resetState();
|
||||
expect(await startRename('first-id', 'First')).toBe(true);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
_activeRename: { sessionId: string } | null;
|
||||
sessions: Map<string, { id: string; name: string }>;
|
||||
startInlineRename: (id: string) => void;
|
||||
};
|
||||
}
|
||||
).app;
|
||||
const firstActive = app._activeRename?.sessionId;
|
||||
// Start a second rename without cancelling — startInlineRename should
|
||||
// pre-emptively cancel the previous one so state never gets stuck on the dead session.
|
||||
app.sessions.set('second-id', { id: 'second-id', name: 'Second' });
|
||||
const wrap = document.createElement('div');
|
||||
wrap.setAttribute('data-test-tab', '1');
|
||||
const tabName = document.createElement('span');
|
||||
tabName.className = 'tab-name';
|
||||
tabName.setAttribute('data-session-id', 'second-id');
|
||||
tabName.textContent = 'Second';
|
||||
wrap.appendChild(tabName);
|
||||
document.body.appendChild(wrap);
|
||||
app.startInlineRename('second-id');
|
||||
return { firstActive, secondActive: app._activeRename?.sessionId };
|
||||
});
|
||||
|
||||
expect(result.firstActive).toBe('first-id');
|
||||
expect(result.secondActive).toBe('second-id');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WebServer } from '../../src/web/server.js';
|
||||
import { WebServer } from '../../../src/web/server.js';
|
||||
|
||||
let servers: Map<number, WebServer> = new Map();
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* Covers:
|
||||
* - SSE subscription filter edge cases (empty params, whitespace, duplicates)
|
||||
* - extractSessionId logic (sessionId vs id field, global events)
|
||||
* - Lifecycle-event broadcast contract (session:*, case:* fan out to all clients;
|
||||
* only session:terminal is filtered by subscription)
|
||||
* - Tab switching: terminal buffer loading, session creation + switch
|
||||
* - Terminal data cap / backpressure recovery
|
||||
* - Lazy teammate terminal lifecycle
|
||||
@@ -254,11 +255,11 @@ describe('Operation Lightspeed', () => {
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// extractSessionId — Event Classification
|
||||
// Lifecycle Event Broadcast — Event Classification
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
describe('extractSessionId via SSE Filtering', () => {
|
||||
it('should route session:updated events by id field', async () => {
|
||||
describe('Lifecycle Event Broadcast Contract', () => {
|
||||
it('should deliver session:updated events to all clients regardless of filter', async () => {
|
||||
// Create two sessions
|
||||
const session1 = await createSession(baseUrl);
|
||||
const session2 = await createSession(baseUrl);
|
||||
@@ -310,21 +311,23 @@ describe('Operation Lightspeed', () => {
|
||||
|
||||
const events = parseSSEEvents(receivedData);
|
||||
|
||||
// Should receive session:updated for session1 only
|
||||
// New contract: session:updated is a lifecycle event that broadcasts to ALL clients.
|
||||
// The subscription filter only applies to session:terminal.
|
||||
const updatedEvents = events.filter((e) => e.event === 'session:updated');
|
||||
const session1Updated = updatedEvents.find((e) => (e.data as any).id === session1);
|
||||
const session2Updated = updatedEvents.find((e) => (e.data as any).id === session2);
|
||||
|
||||
expect(session1Updated).toBeDefined();
|
||||
expect(session2Updated).toBeUndefined();
|
||||
expect(session2Updated).toBeDefined();
|
||||
|
||||
// Cleanup
|
||||
await deleteSession(baseUrl, session1);
|
||||
await deleteSession(baseUrl, session2);
|
||||
});
|
||||
|
||||
it('should filter session:deleted by session ID (sessionId extraction from id field)', async () => {
|
||||
// Tests extractSessionId's fallback path: session:* events use `id` not `sessionId`
|
||||
it('should deliver session:deleted events to all clients regardless of filter', async () => {
|
||||
// New contract: lifecycle events (session:*) broadcast to every connected client;
|
||||
// the per-client filter no longer gates them. Only session:terminal is filtered.
|
||||
const target = await createSession(baseUrl);
|
||||
const other = await createSession(baseUrl);
|
||||
|
||||
@@ -367,13 +370,12 @@ describe('Operation Lightspeed', () => {
|
||||
|
||||
const events = parseSSEEvents(receivedData);
|
||||
|
||||
// Target deletion should arrive (extractSessionId matches `id` field for session:* events)
|
||||
// Both deletions arrive regardless of the per-client filter
|
||||
const targetDeleted = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === target);
|
||||
expect(targetDeleted).toBeDefined();
|
||||
|
||||
// Other deletion should NOT arrive
|
||||
const otherDeleted = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === other);
|
||||
expect(otherDeleted).toBeUndefined();
|
||||
expect(otherDeleted).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -488,13 +490,13 @@ describe('Operation Lightspeed', () => {
|
||||
expect(events.find((e) => e.event === 'init')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle multiple SSE clients with different filters', async () => {
|
||||
it('should fan lifecycle events out to all SSE clients regardless of filter', async () => {
|
||||
const session1 = await createSession(baseUrl);
|
||||
const session2 = await createSession(baseUrl);
|
||||
|
||||
// Client A: subscribes to session1
|
||||
// Client B: subscribes to session2
|
||||
// Client C: no filter (all events)
|
||||
// Client A: subscribes to session1, Client B: subscribes to session2, Client C: no filter.
|
||||
// Under the broadcast contract, all three see every session:deleted event — the filter
|
||||
// only narrows session:terminal traffic.
|
||||
const controllerA = new AbortController();
|
||||
const controllerB = new AbortController();
|
||||
const controllerC = new AbortController();
|
||||
@@ -585,15 +587,13 @@ describe('Operation Lightspeed', () => {
|
||||
const eventsB = parseSSEEvents(dataB);
|
||||
const eventsC = parseSSEEvents(dataC);
|
||||
|
||||
// Client A: sees session1 deleted, not session2
|
||||
// Every client sees both deletions — lifecycle events are not filter-gated.
|
||||
expect(eventsA.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1)).toBeDefined();
|
||||
expect(eventsA.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2)).toBeUndefined();
|
||||
expect(eventsA.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2)).toBeDefined();
|
||||
|
||||
// Client B: sees session2 deleted, not session1
|
||||
expect(eventsB.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1)).toBeDefined();
|
||||
expect(eventsB.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2)).toBeDefined();
|
||||
expect(eventsB.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1)).toBeUndefined();
|
||||
|
||||
// Client C: sees both
|
||||
expect(eventsC.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1)).toBeDefined();
|
||||
expect(eventsC.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2)).toBeDefined();
|
||||
});
|
||||
@@ -991,13 +991,13 @@ describe('Operation Lightspeed', () => {
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// extractSessionId — Additional Edge Cases
|
||||
// Lifecycle Event Broadcast — Additional Edge Cases
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
describe('extractSessionId — Edge Cases via SSE', () => {
|
||||
describe('Lifecycle Event Broadcast — Edge Cases via SSE', () => {
|
||||
it('should treat non-session: events with id field as global (not filtered)', async () => {
|
||||
// Events like case:created have an `id` field but aren't session:* events.
|
||||
// extractSessionId should NOT use the `id` field for non-session:* events.
|
||||
// Under the broadcast contract they reach every connected client.
|
||||
const controller = new AbortController();
|
||||
let receivedData = '';
|
||||
|
||||
@@ -1052,8 +1052,9 @@ describe('Operation Lightspeed', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should deliver session:created for a newly created session to unfiltered client but not mismatched filter', async () => {
|
||||
// session:created uses `id` field and starts with `session:` — extractSessionId should match it
|
||||
it('should deliver session:created to every client, even those with a mismatched filter', async () => {
|
||||
// Under the broadcast contract, lifecycle events ignore the per-client filter.
|
||||
// A client subscribed only to `existing` still receives `session:created` for `newSession`.
|
||||
const existing = await createSession(baseUrl);
|
||||
|
||||
// Subscribe to existing session only
|
||||
@@ -1091,9 +1092,9 @@ describe('Operation Lightspeed', () => {
|
||||
}
|
||||
|
||||
const events = parseSSEEvents(receivedData);
|
||||
// session:created for newSession should be filtered OUT (id doesn't match our filter)
|
||||
// session:created reaches the filtered client even though its id doesn't match the filter.
|
||||
const createdEvent = events.find((e) => e.event === 'session:created' && (e.data as any).id === newSession);
|
||||
expect(createdEvent).toBeUndefined();
|
||||
expect(createdEvent).toBeDefined();
|
||||
|
||||
await Promise.all([deleteSession(baseUrl, existing), deleteSession(baseUrl, newSession)]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Verifies that web-push payloads include the hostname-aware `hostTitle`
|
||||
* field so service-worker OS notifications can disambiguate Codeman
|
||||
* instances on multiple machines (laptop / dev box / NAS).
|
||||
*
|
||||
* The in-page Notification path (notification-manager.js) prefixes with
|
||||
* `${originalTitle}: ${title}` reading from `document.title`. The service
|
||||
* worker has no access to document.title, so the server must ship the
|
||||
* prefix in the push payload itself.
|
||||
*
|
||||
* Strategy: mock the `web-push` module, instantiate WebServer (no port
|
||||
* binding — start() is never called), stub the push store with one fake
|
||||
* subscription, then call the private sendPushNotifications and inspect
|
||||
* the JSON payload handed to webpush.sendNotification.
|
||||
*
|
||||
* Port: N/A (no server start)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// vi.mock is hoisted to the top of the file, so factory captures must use
|
||||
// vi.hoisted() to be initialized before the mocked import is evaluated.
|
||||
const { sendNotification, setVapidDetails, generateVAPIDKeys } = vi.hoisted(() => ({
|
||||
sendNotification: vi.fn(async () => undefined),
|
||||
setVapidDetails: vi.fn(),
|
||||
generateVAPIDKeys: vi.fn(() => ({
|
||||
publicKey: 'test-public-key',
|
||||
privateKey: 'test-private-key',
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('web-push', () => ({
|
||||
default: { sendNotification, setVapidDetails, generateVAPIDKeys },
|
||||
}));
|
||||
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
interface PushPayload {
|
||||
title: string;
|
||||
hostTitle?: string;
|
||||
body: string;
|
||||
tag: string;
|
||||
sessionId: string;
|
||||
urgency: string;
|
||||
actions?: Array<{ action: string; title: string }>;
|
||||
}
|
||||
|
||||
function makeServerWithHost(host: string): WebServer {
|
||||
// Constructor only assigns fields — no network/disk activity until start().
|
||||
const server = new WebServer(0, false, true, host);
|
||||
// Stub push store: one subscription with all events enabled.
|
||||
const fakeSub = {
|
||||
endpoint: 'https://push.example.com/abc',
|
||||
keys: { p256dh: 'k1', auth: 'k2' },
|
||||
pushPreferences: {} as Record<string, boolean>,
|
||||
};
|
||||
const stubStore = {
|
||||
getAll: () => [fakeSub],
|
||||
getVapidKeys: () => ({ publicKey: 'pub', privateKey: 'priv', generatedAt: 0 }),
|
||||
removeByEndpoint: vi.fn(),
|
||||
};
|
||||
(server as unknown as { pushStore: typeof stubStore }).pushStore = stubStore;
|
||||
return server;
|
||||
}
|
||||
|
||||
function lastPayload(): PushPayload {
|
||||
expect(sendNotification).toHaveBeenCalled();
|
||||
const call = sendNotification.mock.calls[sendNotification.mock.calls.length - 1];
|
||||
return JSON.parse(call[1] as string) as PushPayload;
|
||||
}
|
||||
|
||||
describe('push payload hostTitle (Web Push hostname plumbing)', () => {
|
||||
beforeEach(() => {
|
||||
sendNotification.mockClear();
|
||||
setVapidDetails.mockClear();
|
||||
});
|
||||
|
||||
it('includes hostTitle = codeman:<titleHostname> in the payload', () => {
|
||||
const server = makeServerWithHost('laptop');
|
||||
(
|
||||
server as unknown as {
|
||||
sendPushNotifications: (e: string, d: Record<string, unknown>) => void;
|
||||
}
|
||||
).sendPushNotifications('hook:idle_prompt', {
|
||||
sessionId: 's-1',
|
||||
sessionName: 'mysession',
|
||||
});
|
||||
|
||||
const payload = lastPayload();
|
||||
expect(payload.hostTitle).toBe('codeman:laptop');
|
||||
// The bare event title is preserved separately so the SW can compose them.
|
||||
expect(payload.title).toBe('Waiting for Input');
|
||||
});
|
||||
|
||||
it('falls back to os.hostname() when --title-hostname is not provided', () => {
|
||||
const server = makeServerWithHost(''); // empty -> constructor uses getHostname()
|
||||
(
|
||||
server as unknown as {
|
||||
sendPushNotifications: (e: string, d: Record<string, unknown>) => void;
|
||||
}
|
||||
).sendPushNotifications('hook:permission_prompt', {
|
||||
sessionId: 's-2',
|
||||
sessionName: 'sess',
|
||||
tool_name: 'Bash',
|
||||
});
|
||||
|
||||
const payload = lastPayload();
|
||||
expect(payload.hostTitle).toMatch(/^codeman:.+/);
|
||||
expect(payload.hostTitle).not.toBe('codeman:');
|
||||
expect(payload.title).toBe('Permission Required');
|
||||
});
|
||||
|
||||
it('different WebServer instances ship distinct hostTitles', () => {
|
||||
const a = makeServerWithHost('host-a');
|
||||
const b = makeServerWithHost('host-b');
|
||||
|
||||
(
|
||||
a as unknown as {
|
||||
sendPushNotifications: (e: string, d: Record<string, unknown>) => void;
|
||||
}
|
||||
).sendPushNotifications('hook:stop', { sessionId: 's-a', sessionName: 'A' });
|
||||
(
|
||||
b as unknown as {
|
||||
sendPushNotifications: (e: string, d: Record<string, unknown>) => void;
|
||||
}
|
||||
).sendPushNotifications('hook:stop', { sessionId: 's-b', sessionName: 'B' });
|
||||
|
||||
expect(sendNotification).toHaveBeenCalledTimes(2);
|
||||
const first = JSON.parse(sendNotification.mock.calls[0][1] as string) as PushPayload;
|
||||
const second = JSON.parse(sendNotification.mock.calls[1][1] as string) as PushPayload;
|
||||
expect(first.hostTitle).toBe('codeman:host-a');
|
||||
expect(second.hostTitle).toBe('codeman:host-b');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SW display-title formatting ─────────────────────────────────────────
|
||||
// The SW logic at sw.js:130 composes the OS notification title from the
|
||||
// payload. It's a 3-line conditional we mirror here so any future change
|
||||
// (e.g. swapping the separator) shows up in this test instead of being
|
||||
// caught only by users running multiple Codeman instances.
|
||||
|
||||
function computeSwDisplayTitle(payload: { title?: string; hostTitle?: string }): string {
|
||||
const { title, hostTitle } = payload;
|
||||
return hostTitle && title ? `${hostTitle}: ${title}` : title || hostTitle || 'Codeman';
|
||||
}
|
||||
|
||||
describe('service worker displayTitle composition (mirrors sw.js)', () => {
|
||||
it('joins host and title with ": " when both present', () => {
|
||||
expect(computeSwDisplayTitle({ hostTitle: 'codeman:laptop', title: 'Permission Required' })).toBe(
|
||||
'codeman:laptop: Permission Required'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to bare title when hostTitle is missing (older server)', () => {
|
||||
expect(computeSwDisplayTitle({ title: 'Permission Required' })).toBe('Permission Required');
|
||||
});
|
||||
|
||||
it('falls back to hostTitle alone when title is missing', () => {
|
||||
expect(computeSwDisplayTitle({ hostTitle: 'codeman:laptop' })).toBe('codeman:laptop');
|
||||
});
|
||||
|
||||
it('defaults to "Codeman" when both missing', () => {
|
||||
expect(computeSwDisplayTitle({})).toBe('Codeman');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Verifies that WebServer templates the `<title>` tag in the served
|
||||
* index.html with the hostname-aware `codeman:<host>` window title
|
||||
* (feature #82). The title must:
|
||||
* - default to `codeman:<os.hostname()>` when no override is supplied
|
||||
* - honor a custom `titleHostname` passed via the constructor (CLI flag
|
||||
* `--title-hostname <host>` plumbs through to here)
|
||||
* - HTML-escape the hostname so a value like `<script>foo</script>`
|
||||
* can't break out of the title tag
|
||||
* - replace the bare `<title>Codeman</title>` literal exactly once
|
||||
* - leave the rest of the document byte-for-byte identical to the
|
||||
* template on disk
|
||||
*
|
||||
* Strategy: construct WebServer with port 0 / testMode (no network
|
||||
* activity until start()) and call the private `renderIndexHtml()`
|
||||
* method directly. The Fastify `/` and `/index.html` route handlers
|
||||
* are one-liners that call exactly this method (server.ts:539-544),
|
||||
* so testing the render function covers both endpoints without
|
||||
* needing to listen on a port.
|
||||
*
|
||||
* Port: N/A (no server start)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { hostname as osHostname } from 'node:os';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const indexHtmlPath = join(__dirname, '..', 'src', 'web', 'public', 'index.html');
|
||||
const rawTemplate = readFileSync(indexHtmlPath, 'utf-8');
|
||||
|
||||
function render(host?: string): string {
|
||||
const server = new WebServer(0, false, true, host);
|
||||
return (server as unknown as { renderIndexHtml: () => string }).renderIndexHtml();
|
||||
}
|
||||
|
||||
describe('WebServer index.html <title> templating (#82)', () => {
|
||||
it('substitutes the bare <title>Codeman</title> with codeman:<host>', () => {
|
||||
const html = render('laptop');
|
||||
expect(html).toContain('<title>codeman:laptop</title>');
|
||||
expect(html).not.toContain('<title>Codeman</title>');
|
||||
});
|
||||
|
||||
it('defaults to os.hostname() when no titleHostname is supplied', () => {
|
||||
const html = render();
|
||||
const expected = `<title>codeman:${osHostname()}</title>`;
|
||||
expect(html).toContain(expected);
|
||||
});
|
||||
|
||||
it('treats an empty-string titleHostname as "not supplied" and falls back to os.hostname()', () => {
|
||||
// CLI normally guarantees a non-empty string, but the constructor's
|
||||
// `titleHostname || getHostname()` guard makes empty fall through —
|
||||
// pin that behavior so a future refactor doesn't accidentally ship
|
||||
// a `<title>codeman:</title>` to users.
|
||||
const html = render('');
|
||||
expect(html).toMatch(/<title>codeman:.+<\/title>/);
|
||||
expect(html).not.toContain('<title>codeman:</title>');
|
||||
});
|
||||
|
||||
it('HTML-escapes < > & in the hostname so it cannot break out of the title tag', () => {
|
||||
const html = render('<script>alert(1)</script>');
|
||||
expect(html).toContain('<title>codeman:<script>alert(1)</script></title>');
|
||||
// The raw closing </title> from the injected payload must NOT appear
|
||||
// outside the actual title element — escape-then-substitute prevents
|
||||
// an attacker-controlled hostname from terminating the tag early.
|
||||
expect(html).not.toContain('<script>alert(1)</script></title>');
|
||||
});
|
||||
|
||||
it('escapes an ampersand without double-encoding existing entities', () => {
|
||||
// The escaper replaces & first, then < and >. A hostname that already
|
||||
// contains a literal `&` should render as `&` once, not `&amp;`.
|
||||
const html = render('a&b');
|
||||
expect(html).toContain('<title>codeman:a&b</title>');
|
||||
expect(html).not.toContain('&amp;');
|
||||
});
|
||||
|
||||
it('only substitutes the <title> tag — the rest of the template is byte-for-byte identical', () => {
|
||||
const html = render('laptop');
|
||||
const beforeTitle = rawTemplate.split('<title>Codeman</title>')[0];
|
||||
const afterTitle = rawTemplate.split('<title>Codeman</title>')[1];
|
||||
expect(html.startsWith(beforeTitle)).toBe(true);
|
||||
expect(html.endsWith(afterTitle)).toBe(true);
|
||||
// Sanity check: length differs only by the title swap.
|
||||
const expectedDelta = `<title>codeman:laptop</title>`.length - `<title>Codeman</title>`.length;
|
||||
expect(html.length - rawTemplate.length).toBe(expectedDelta);
|
||||
});
|
||||
|
||||
it('replaces the <title> placeholder exactly once', () => {
|
||||
const html = render('laptop');
|
||||
// Defense against a future regression where the template gains a
|
||||
// second `<title>Codeman</title>` (e.g. inside a <noscript>) and only
|
||||
// the first gets templated — would leave a stale literal in the served
|
||||
// HTML that overrides the correct one in some renderers.
|
||||
const occurrencesOfNew = html.split('<title>codeman:laptop</title>').length - 1;
|
||||
const occurrencesOfOld = html.split('<title>Codeman</title>').length - 1;
|
||||
expect(occurrencesOfNew).toBe(1);
|
||||
expect(occurrencesOfOld).toBe(0);
|
||||
});
|
||||
|
||||
it('two WebServer instances on different hostnames render distinct titles', () => {
|
||||
const htmlA = render('host-a');
|
||||
const htmlB = render('host-b');
|
||||
expect(htmlA).toContain('<title>codeman:host-a</title>');
|
||||
expect(htmlB).toContain('<title>codeman:host-b</title>');
|
||||
expect(htmlA).not.toContain('host-b');
|
||||
expect(htmlB).not.toContain('host-a');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Regression tests for stripInkRedrawBloat() in session-routes.ts.
|
||||
*
|
||||
* Background: this helper trims the dense VPA (CSI n d) escape clusters that
|
||||
* Ink emits while animating the spinner / status bar so terminal-tail responses
|
||||
* stay manageable. The previous implementation collapsed *everything* after
|
||||
* the first VPA, which silently discarded 100KB+ of legitimate streamed
|
||||
* response text. The clustering rewrite shipped silently inside the v0.6.7
|
||||
* "chore: version packages" commit (dcc814f) — these tests lock the algorithm
|
||||
* down so neither the silent data-loss bug nor the threshold constants
|
||||
* (FRAME_GAP=8KB, MIN_BLOAT_SIZE=32KB) regress.
|
||||
*
|
||||
* Pure (string)=>string helper, no I/O — synchronous tests, no port needed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { stripInkRedrawBloat } from '../src/web/routes/session-routes.js';
|
||||
|
||||
const VPA = '\x1b[10d'; // VPA escape: move cursor to row 10. 5 bytes.
|
||||
const VPA_LEN = VPA.length;
|
||||
// Single counting regex shared across tests so the no-control-regex
|
||||
// disable lives in one place.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const VPA_RE = /\x1b\[\d+d/g;
|
||||
function countVpa(s: string): number {
|
||||
return (s.match(VPA_RE) || []).length;
|
||||
}
|
||||
|
||||
/** Build a buffer of `count` VPAs with `gap` bytes of filler between them. */
|
||||
function vpaCluster(count: number, gap: number, fillerChar = ' '): string {
|
||||
const filler = fillerChar.repeat(gap);
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (i > 0) parts.push(filler);
|
||||
parts.push(VPA);
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/** Span (bytes from first VPA's start to last VPA's start) of a cluster
|
||||
* built by vpaCluster(count, gap). */
|
||||
function clusterSpan(count: number, gap: number): number {
|
||||
return (count - 1) * (gap + VPA_LEN);
|
||||
}
|
||||
|
||||
describe('stripInkRedrawBloat', () => {
|
||||
// ─── Early-out paths ─────────────────────────────────────────────────────
|
||||
|
||||
it('returns empty buffer unchanged', () => {
|
||||
expect(stripInkRedrawBloat('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns buffer with no VPA escapes unchanged', () => {
|
||||
const buf = 'Hello, this is a normal Claude response.\n'.repeat(100);
|
||||
expect(stripInkRedrawBloat(buf)).toBe(buf);
|
||||
});
|
||||
|
||||
it('returns buffer with fewer than 10 VPAs unchanged (even if total is large)', () => {
|
||||
// 9 VPAs is below the early-out threshold; helper returns input verbatim
|
||||
// regardless of how much filler sits between them.
|
||||
const buf = vpaCluster(9, 50_000); // ~450KB of filler
|
||||
expect(stripInkRedrawBloat(buf)).toBe(buf);
|
||||
});
|
||||
|
||||
// ─── Small cluster preserved ─────────────────────────────────────────────
|
||||
|
||||
it('preserves a cluster smaller than MIN_BLOAT_SIZE (32KB span)', () => {
|
||||
// 50 VPAs, 100B apart -> span ~5.2KB, well under 32KB
|
||||
const cluster = vpaCluster(50, 100);
|
||||
expect(clusterSpan(50, 100)).toBeLessThan(32 * 1024);
|
||||
const buf = 'PREFIX\n' + cluster + '\nSUFFIX';
|
||||
expect(stripInkRedrawBloat(buf)).toBe(buf);
|
||||
});
|
||||
|
||||
// ─── Big cluster collapsed ───────────────────────────────────────────────
|
||||
|
||||
it('collapses a single big cluster but preserves bytes before and after', () => {
|
||||
// 50 VPAs, 700B apart -> span 49*(700+5) = 34_545B, comfortably over 32KB
|
||||
const cluster = vpaCluster(50, 700);
|
||||
expect(clusterSpan(50, 700)).toBeGreaterThanOrEqual(32 * 1024);
|
||||
const prefix = 'BEFORE_THE_BLOAT\n';
|
||||
const suffix = '\nAFTER_THE_BLOAT_THIS_IS_THE_RESPONSE_TEXT_THAT_USED_TO_BE_LOST';
|
||||
const buf = prefix + cluster + suffix;
|
||||
|
||||
const out = stripInkRedrawBloat(buf);
|
||||
|
||||
// Both ends survive — that was the silent bug
|
||||
expect(out.startsWith(prefix)).toBe(true);
|
||||
expect(out.endsWith(suffix)).toBe(true);
|
||||
// Output is much shorter than the input
|
||||
expect(out.length).toBeLessThan(buf.length);
|
||||
// Exactly one VPA remains (the last frame's), not all 50
|
||||
expect(countVpa(out)).toBe(1);
|
||||
});
|
||||
|
||||
// ─── THE REGRESSION: response text BETWEEN big clusters survives ─────────
|
||||
|
||||
it('preserves response text between two big clusters (the silent-data-loss bug)', () => {
|
||||
// Make each cluster large enough that an old "keep just the tail" approach
|
||||
// (the audit described it as "keep only the last 64KB after the first VPA")
|
||||
// would push any in-between response text out of the kept window.
|
||||
// 200 VPAs at 700B gap -> ~140KB span per cluster, total buffer >300KB.
|
||||
const clusterA = vpaCluster(200, 700);
|
||||
const clusterB = vpaCluster(200, 700);
|
||||
expect(clusterSpan(200, 700)).toBeGreaterThan(64 * 1024);
|
||||
|
||||
const responseText =
|
||||
'\n\n## Here is my detailed answer\n\n' +
|
||||
'This is the kind of streamed response text that the old first-VPA\n' +
|
||||
'algorithm silently dropped. Multiple paragraphs of it. Indented blocks,\n' +
|
||||
'code samples, the entire conversation. Losing this was a silent bug\n' +
|
||||
'that the changelog mentioned but no test had ever locked down.\n\n' +
|
||||
'```ts\n' +
|
||||
'function example() { return 42; }\n' +
|
||||
'```\n\n' +
|
||||
'And a closing paragraph.';
|
||||
// Gap between clusters must exceed FRAME_GAP (8KB) so they're treated
|
||||
// as separate clusters, not merged into one giant one.
|
||||
const padded = responseText + '\n' + ' '.repeat(8 * 1024 + 100);
|
||||
const buf = 'INTRO\n' + clusterA + padded + clusterB + '\nOUTRO';
|
||||
expect(buf.length).toBeGreaterThan(280 * 1024); // sanity: well past any 64KB window
|
||||
|
||||
const out = stripInkRedrawBloat(buf);
|
||||
|
||||
// Every paragraph of response text survives intact. This is the assertion
|
||||
// that would have caught the silent-data-loss bug — under a "keep the last
|
||||
// 64KB after the first VPA" approach, all of responseText falls outside
|
||||
// the kept window and is lost.
|
||||
expect(out).toContain('Here is my detailed answer');
|
||||
expect(out).toContain('the old first-VPA');
|
||||
expect(out).toContain('function example() { return 42; }');
|
||||
expect(out).toContain('And a closing paragraph.');
|
||||
// Bookends survive too.
|
||||
expect(out.startsWith('INTRO\n')).toBe(true);
|
||||
expect(out.endsWith('\nOUTRO')).toBe(true);
|
||||
// Each big cluster collapses to a single VPA (its last frame).
|
||||
expect(countVpa(out)).toBe(2);
|
||||
});
|
||||
|
||||
// ─── Mixed: small clusters preserved alongside big ones ──────────────────
|
||||
|
||||
it('preserves small clusters when a big cluster is also present', () => {
|
||||
const small = vpaCluster(50, 100); // ~5.2KB span, kept as-is
|
||||
const big = vpaCluster(50, 700); // ~34KB span, collapsed
|
||||
// Separate them with > FRAME_GAP filler so they stay distinct clusters.
|
||||
const gap = ' '.repeat(8 * 1024 + 100);
|
||||
const buf = small + gap + 'MID_CONTENT' + gap + big + 'TAIL';
|
||||
|
||||
const out = stripInkRedrawBloat(buf);
|
||||
|
||||
// Small cluster survives intact: all 50 VPAs still there.
|
||||
// Big cluster collapsed to 1 VPA. Total = 50 + 1 = 51.
|
||||
expect(countVpa(out)).toBe(51);
|
||||
expect(out).toContain('MID_CONTENT');
|
||||
expect(out.endsWith('TAIL')).toBe(true);
|
||||
});
|
||||
|
||||
// ─── FRAME_GAP boundary: two clusters separated by exactly > 8KB ─────────
|
||||
|
||||
it('treats VPAs separated by > FRAME_GAP (8KB) as different clusters', () => {
|
||||
// Two big clusters with a 9KB gap between them must NOT be merged.
|
||||
const clusterA = vpaCluster(50, 700);
|
||||
const clusterB = vpaCluster(50, 700);
|
||||
const gap = ' '.repeat(9 * 1024); // > 8KB FRAME_GAP
|
||||
|
||||
const buf = clusterA + gap + clusterB;
|
||||
const out = stripInkRedrawBloat(buf);
|
||||
|
||||
// Two separate big clusters -> 2 VPAs survive.
|
||||
expect(countVpa(out)).toBe(2);
|
||||
// Gap content survives.
|
||||
expect(out).toContain(gap);
|
||||
});
|
||||
|
||||
it('treats VPAs separated by <= FRAME_GAP (8KB) as the same cluster', () => {
|
||||
// Two would-be-separate clusters with a 1KB gap merge into one big cluster.
|
||||
const left = vpaCluster(30, 700);
|
||||
const right = vpaCluster(30, 700);
|
||||
const gap = ' '.repeat(1024); // well under 8KB FRAME_GAP
|
||||
const buf = left + gap + right;
|
||||
|
||||
const out = stripInkRedrawBloat(buf);
|
||||
// Merged into one big cluster -> 1 VPA survives, gap content is gone too.
|
||||
expect(countVpa(out)).toBe(1);
|
||||
});
|
||||
|
||||
// ─── Big cluster at end of buffer ────────────────────────────────────────
|
||||
|
||||
it('preserves the last frame when a big cluster is at the end of the buffer', () => {
|
||||
const cluster = vpaCluster(50, 700);
|
||||
const buf = 'HEAD\n' + cluster;
|
||||
const out = stripInkRedrawBloat(buf);
|
||||
|
||||
expect(out.startsWith('HEAD\n')).toBe(true);
|
||||
// Exactly one VPA (the last frame's) at the tail.
|
||||
expect(countVpa(out)).toBe(1);
|
||||
expect(out.endsWith(VPA)).toBe(true);
|
||||
});
|
||||
|
||||
// ─── Idempotency ─────────────────────────────────────────────────────────
|
||||
|
||||
it('is idempotent: stripping an already-stripped buffer is a no-op', () => {
|
||||
// After one pass a big cluster shrinks to ~1 VPA, putting the buffer
|
||||
// below the early-out threshold (positions.length < 10), so a second
|
||||
// pass returns it untouched.
|
||||
const buf = 'A' + vpaCluster(50, 700) + 'B';
|
||||
const once = stripInkRedrawBloat(buf);
|
||||
const twice = stripInkRedrawBloat(once);
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
|
||||
// ─── Realistic size guardrail ────────────────────────────────────────────
|
||||
|
||||
it('shrinks a 200KB Ink-bloat buffer down by an order of magnitude', () => {
|
||||
// Roughly the shape of a real "Claude is thinking" terminal buffer.
|
||||
const cluster = vpaCluster(300, 700); // ~210KB span
|
||||
const buf = 'Question: Tell me about TypeScript.\n' + cluster + '\nAnswer: TypeScript is...';
|
||||
|
||||
const out = stripInkRedrawBloat(buf);
|
||||
expect(buf.length).toBeGreaterThan(200 * 1024);
|
||||
expect(out.length).toBeLessThan(buf.length / 10);
|
||||
expect(out).toContain('Question: Tell me about TypeScript.');
|
||||
expect(out).toContain('Answer: TypeScript is...');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Covers `queryTmuxWindowSize()`, the helper extracted from `_attachToMux`
|
||||
* in PR #80 ("prevent tmux flicker on restart by matching existing window size").
|
||||
*
|
||||
* Before #80, the PTY was hardcoded to 120x40 on every attach. If a previous
|
||||
* client had resized the tmux window to e.g. 200x50, the re-attach would
|
||||
* shrink the window back to 120x40, then xterm.js would resize it again on
|
||||
* the next frame — visible flicker and one lost repaint of scrollback.
|
||||
*
|
||||
* The fix queries tmux for the actual window geometry first via
|
||||
* `tmux display -t <name> -p '#{window_width} #{window_height}'`. We cover:
|
||||
* - Happy path: tmux reports valid geometry → those numbers are used.
|
||||
* - Browser-resize-between-attaches: tmux reports a non-default size
|
||||
* (because a prior client resized it) → the helper picks that up.
|
||||
* - Query-then-die race: tmux dies between query and attach → the query
|
||||
* either throws or returns garbage; either way the helper falls back to
|
||||
* 120x40 so the attach can still proceed (the pty.spawn that follows has
|
||||
* its own try/catch for the actual failed-attach case).
|
||||
* - Defensive paths: empty output, non-numeric output, zero/negative
|
||||
* dimensions, trailing whitespace.
|
||||
* - Security: muxName is passed as an argv element (no shell), so a
|
||||
* malicious mux name can't inject options.
|
||||
*
|
||||
* Strategy: mock `node:child_process.execFileSync` and assert both the call
|
||||
* shape (argv, timeout) and the parsed return.
|
||||
*
|
||||
* Port: N/A (no server / no real tmux)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { execFileSync } = vi.hoisted(() => ({
|
||||
execFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:child_process')>('node:child_process');
|
||||
return { ...actual, execFileSync };
|
||||
});
|
||||
|
||||
import { queryTmuxWindowSize } from '../src/session.js';
|
||||
|
||||
const DEFAULT = { cols: 120, rows: 40 };
|
||||
|
||||
beforeEach(() => {
|
||||
execFileSync.mockReset();
|
||||
});
|
||||
|
||||
describe('queryTmuxWindowSize — happy path', () => {
|
||||
it('returns the geometry tmux reports', () => {
|
||||
execFileSync.mockReturnValue('200 50\n');
|
||||
expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 200, rows: 50 });
|
||||
});
|
||||
|
||||
it('picks up a non-default size left behind by a prior client (browser-resize-between-attaches)', () => {
|
||||
// Scenario: client A attached at 220x60, resized tmux to that, then disconnected.
|
||||
// tmux keeps the last-attached geometry. Client B re-attaches and should spawn
|
||||
// its PTY at 220x60, not 120x40 — that's the whole point of #80.
|
||||
execFileSync.mockReturnValue('220 60');
|
||||
expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 220, rows: 60 });
|
||||
});
|
||||
|
||||
it('tolerates trailing whitespace and newlines in tmux output', () => {
|
||||
execFileSync.mockReturnValue(' 180 45 \n\n');
|
||||
expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 180, rows: 45 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryTmuxWindowSize — fallback paths', () => {
|
||||
it('falls back to 120x40 when tmux exits non-zero (process not found)', () => {
|
||||
// execFileSync throws when the child exits non-zero. Simulates `tmux` binary
|
||||
// missing or `display -t` failing because the target session doesn't exist.
|
||||
execFileSync.mockImplementation(() => {
|
||||
const err = new Error('Command failed: tmux display -t bogus') as Error & { status: number };
|
||||
err.status = 1;
|
||||
throw err;
|
||||
});
|
||||
expect(queryTmuxWindowSize('bogus')).toEqual(DEFAULT);
|
||||
});
|
||||
|
||||
it('falls back when tmux dies between query and parse (ETIMEDOUT / ENOENT)', () => {
|
||||
// Query-then-die race: simulates the tmux server being killed mid-call.
|
||||
execFileSync.mockImplementation(() => {
|
||||
const err = new Error('spawn ETIMEDOUT') as NodeJS.ErrnoException;
|
||||
err.code = 'ETIMEDOUT';
|
||||
throw err;
|
||||
});
|
||||
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
|
||||
});
|
||||
|
||||
it('falls back when tmux returns empty output', () => {
|
||||
execFileSync.mockReturnValue('');
|
||||
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
|
||||
});
|
||||
|
||||
it('falls back when tmux returns whitespace-only output', () => {
|
||||
execFileSync.mockReturnValue(' \n');
|
||||
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
|
||||
});
|
||||
|
||||
it('falls back when tmux returns non-numeric output', () => {
|
||||
execFileSync.mockReturnValue('not a size\n');
|
||||
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
|
||||
});
|
||||
|
||||
it('falls back when only one dimension is present', () => {
|
||||
execFileSync.mockReturnValue('200\n');
|
||||
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
|
||||
});
|
||||
|
||||
it('falls back when a dimension is zero (degenerate geometry)', () => {
|
||||
// tmux reporting `0` would crash node-pty downstream — must not propagate.
|
||||
execFileSync.mockReturnValue('0 40\n');
|
||||
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
|
||||
execFileSync.mockReturnValue('120 0\n');
|
||||
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
|
||||
});
|
||||
|
||||
it('falls back when a dimension is negative', () => {
|
||||
execFileSync.mockReturnValue('-200 -50\n');
|
||||
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
|
||||
});
|
||||
|
||||
it('falls back when tmux returns NaN-producing tokens', () => {
|
||||
execFileSync.mockReturnValue('abc def\n');
|
||||
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryTmuxWindowSize — call shape', () => {
|
||||
it('invokes tmux with display -t <name> -p ... via argv (not a shell)', () => {
|
||||
execFileSync.mockReturnValue('120 40\n');
|
||||
queryTmuxWindowSize('codeman-abc');
|
||||
expect(execFileSync).toHaveBeenCalledTimes(1);
|
||||
const [bin, argv, opts] = execFileSync.mock.calls[0];
|
||||
expect(bin).toBe('tmux');
|
||||
expect(argv).toEqual(['display', '-t', 'codeman-abc', '-p', '#{window_width} #{window_height}']);
|
||||
// execFileSync — not execSync — so muxName is never substituted into a shell string.
|
||||
expect(opts).toMatchObject({ encoding: 'utf8' });
|
||||
});
|
||||
|
||||
it('uses a bounded timeout so a hung tmux server cannot block startup forever', () => {
|
||||
execFileSync.mockReturnValue('120 40\n');
|
||||
queryTmuxWindowSize('codeman-abc');
|
||||
const [, , opts] = execFileSync.mock.calls[0];
|
||||
// Whatever the exact constant, the contract is: ≤5s so the user-visible
|
||||
// attach path can't hang on a stuck tmux server.
|
||||
expect(typeof opts?.timeout).toBe('number');
|
||||
expect(opts?.timeout).toBeGreaterThan(0);
|
||||
expect(opts?.timeout).toBeLessThanOrEqual(5000);
|
||||
});
|
||||
|
||||
it('passes a muxName that looks like a tmux flag as an argv element (no option injection)', () => {
|
||||
execFileSync.mockReturnValue('120 40\n');
|
||||
queryTmuxWindowSize('-x 1 -y 1; rm -rf');
|
||||
const [, argv] = execFileSync.mock.calls[0];
|
||||
// The whole "name" lives in a single argv slot, so tmux interprets it as a
|
||||
// target session name, not as additional flags. The `-t` flag preceding it
|
||||
// pins it as the target argument.
|
||||
expect(argv?.[2]).toBe('-x 1 -y 1; rm -rf');
|
||||
expect((argv as string[]).indexOf('-x')).toBe(-1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user