mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
co-authored by
Claude Opus 4.6
parent
eade261763
commit
b7e94e7068
@@ -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
@@ -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'));
|
||||
|
||||
@@ -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
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
Reference in New Issue
Block a user