test: cover hostname title (#82) and tmux size-query (#80)

Backfill the two regression gaps flagged on master after the recent
hostname-title and tmux-flicker fixes shipped without server-side
assertions.

* test/server-index-title.test.ts (8 tests) — exercises WebServer's
  index.html templating path: default os.hostname(), --title-hostname
  override, HTML-escape against `<script>`-style breakout, ampersand
  non-double-encoding, exact-once substitution, and byte-identical
  template-tail invariance.

* test/tmux-window-size-query.test.ts (15 tests) — mocks
  child_process.execFileSync and walks the helper through the
  browser-resize-between-attaches happy path, query-then-die race,
  zero/negative/empty/non-numeric output, plus argv-form/timeout
  assertions to lock down the no-shell-interpolation guarantee.

* src/session.ts — extracts the inline 14-line tmux size query into
  a named `queryTmuxWindowSize()` export so the test surface is a
  pure function. Behavior unchanged.

* src/web/public/notification-manager.js — Browser Notification API
  (layer 3) now uses `${this.originalTitle}: ${title}` so OS-level
  desktop pop-ups carry the same `codeman:<host>` prefix that the
  tab title and Web Push payloads already do, finishing the
  hostname plumb-through started in #82.

* CLAUDE.md, README.md — document the dual-CLI env-prefix discipline
  (CLAUDE_CODE_* vs OPENCODE_*), expand the xterm-zerolag-input
  duplication gotcha to mention the published-package side-effect,
  and note that the hostname prefix now applies uniformly to tab
  title, tab-flash, and OS notifications.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-05-12 10:23:44 +02:00
co-authored by Claude Opus 4.7
parent e7b95ae579
commit 453a5383d2
6 changed files with 312 additions and 21 deletions
+3 -2
View File
@@ -77,7 +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()` — tab title renders as `codeman:<name>`) |
| 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`) |
@@ -95,8 +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 package) AND inline inside `src/web/public/app.js` (runtime copy used by the web UI). Any change to overlay behavior MUST be applied to both, or dev and prod diverge. Always test on mobile after touching it.
- **`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.
+1 -1
View File
@@ -235,7 +235,7 @@ 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 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
+32 -16
View File
@@ -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.
@@ -947,22 +978,7 @@ 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)
let ptyCols = 120;
let ptyRows = 40;
try {
const sizeStr = execFileSync(
'tmux',
['display', '-t', this._muxSession!.muxName, '-p', '#{window_width} #{window_height}'],
{ timeout: 2000, encoding: 'utf8' }
).trim();
const [w, h] = sizeStr.split(' ').map(Number);
if (w > 0 && h > 0) {
ptyCols = w;
ptyRows = h;
}
} catch {
/* fall back to 120x40 */
}
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName);
try {
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
name: 'xterm-256color',
+2 -2
View File
@@ -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)
@@ -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',
+111
View File
@@ -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:&lt;script&gt;alert(1)&lt;/script&gt;</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 `&amp;` once, not `&amp;amp;`.
const html = render('a&b');
expect(html).toContain('<title>codeman:a&amp;b</title>');
expect(html).not.toContain('&amp;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');
});
});
+163
View File
@@ -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);
});
});