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
+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;
}