feat: hostname-aware window title (#82)

Set the browser tab title to codeman:${hostname} instead of the bare
"Codeman" literal. Useful for users running multiple Codeman instances
across hosts (laptop, dev box, NAS) — the OS hostname disambiguates
which tab points at which backend.

Implementation:

- src/cli.ts: new --title-hostname <hostname> flag overrides the
  detected hostname (handy for cosmetic naming or when os.hostname()
  returns something noisy).
- src/web/server.ts: WebServer now accepts an optional titleHostname
  constructor arg (defaults to os.hostname()), composes
  windowTitle = codeman:${titleHostname}, and serves / and
  /index.html by templating that title into the cached index.html
  template (with HTML escaping of the title text).
- src/web/public/notification-manager.js: title-flash logic now uses
  this.originalTitle instead of the hardcoded "Codeman" literal, so
  the tab flash respects the per-host title.
- scripts/browser-comparison.mjs + test/file-link-click.test.ts:
  expectations updated from === "Codeman" to a startsWith("codeman:")
  predicate so they pass regardless of host.

The new index.html templating is intentionally narrow — it only
substitutes the <title> tag and continues to serve everything else
from the static template. No JS-side title injection, so it works
without JavaScript and shows the correct title from the very first
paint.

Note: test/file-link-click.test.ts shows ~49 prettier-reformat lines
that are not part of the feature — they are pre-existing prettier
debt that the pre-commit hook required me to clear. The single
behavioral change is the browserAvailable line.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
aakhter
2026-05-12 09:11:33 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent eade261763
commit b7e94e7068
5 changed files with 71 additions and 31 deletions
+8 -4
View File
@@ -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
+3 -1
View File
@@ -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'));
+2 -2
View File
@@ -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}`;
}
}
}
+28 -4
View File
@@ -37,7 +37,7 @@ import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync } 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,10 @@ import {
const __dirname = dirname(fileURLToPath(import.meta.url));
function escapeHtmlText(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}
import {
SESSIONS_LIST_CACHE_TTL,
SCHEDULED_CLEANUP_INTERVAL,
@@ -226,12 +230,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 +536,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
@@ -922,6 +938,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);
@@ -1970,9 +1993,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;
}
+25 -15
View File
@@ -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');
@@ -259,7 +267,8 @@ 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,7 +318,8 @@ 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',