mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-29 04:19:33 +01:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcc814f40c | ||
|
|
b7e94e7068 | ||
|
|
eade261763 |
No files matched your search
@@ -1,5 +1,15 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 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
|
## 0.6.6
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ When user says "COM":
|
|||||||
|
|
||||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
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.6 (must match `package.json`)
|
**Version**: 0.6.7 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -77,6 +77,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
| Task | Command |
|
| Task | Command |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| Dev with TLS | `npx tsx src/index.ts web --https` |
|
| 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>`) |
|
||||||
| Continuous typecheck | `tsc --noEmit --watch` |
|
| Continuous typecheck | `tsc --noEmit --watch` |
|
||||||
| Test coverage | `npm run test:coverage` |
|
| Test coverage | `npm run test:coverage` |
|
||||||
| Dead-code sweep | `npm run knip` (config in `knip.json`) |
|
| Dead-code sweep | `npm run knip` (config in `knip.json`) |
|
||||||
|
|||||||
@@ -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.
|
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.
|
||||||
|
|
||||||
### Smart Token Management
|
### Smart Token Management
|
||||||
|
|
||||||
| Threshold | Action | Result |
|
| Threshold | Action | Result |
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "0.6.6",
|
"version": "0.6.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "0.6.6",
|
"version": "0.6.7",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "0.6.6",
|
"version": "0.6.7",
|
||||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ const PORTS = {
|
|||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
|
function isCodemanTitle(title) {
|
||||||
|
return typeof title === 'string' && title.startsWith('codeman:');
|
||||||
|
}
|
||||||
|
|
||||||
function logSection(title) {
|
function logSection(title) {
|
||||||
console.log('\n' + '='.repeat(60));
|
console.log('\n' + '='.repeat(60));
|
||||||
console.log(` ${title}`);
|
console.log(` ${title}`);
|
||||||
@@ -88,7 +92,7 @@ async function main() {
|
|||||||
const page = await playwrightBrowser.newPage();
|
const page = await playwrightBrowser.newPage();
|
||||||
await page.goto(`http://localhost:${PORTS.playwright}`);
|
await page.goto(`http://localhost:${PORTS.playwright}`);
|
||||||
const title = await page.title();
|
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();
|
await page.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,7 +153,7 @@ async function main() {
|
|||||||
const page = await puppeteerBrowser.newPage();
|
const page = await puppeteerBrowser.newPage();
|
||||||
await page.goto(`http://localhost:${PORTS.puppeteer}`);
|
await page.goto(`http://localhost:${PORTS.puppeteer}`);
|
||||||
const title = await page.title();
|
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();
|
await page.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,7 +206,7 @@ async function main() {
|
|||||||
agentBrowser(`open http://localhost:${PORTS.agentBrowser}`);
|
agentBrowser(`open http://localhost:${PORTS.agentBrowser}`);
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
const title = agentBrowserJson('get title');
|
const title = agentBrowserJson('get title');
|
||||||
agentBrowserAvailable = title.title === 'Codeman';
|
agentBrowserAvailable = isCodemanTitle(title.title);
|
||||||
console.log(' Browser launched');
|
console.log(' Browser launched');
|
||||||
|
|
||||||
// Test 1: Page load
|
// Test 1: Page load
|
||||||
@@ -210,7 +214,7 @@ async function main() {
|
|||||||
agentBrowser(`open http://localhost:${PORTS.agentBrowser}`);
|
agentBrowser(`open http://localhost:${PORTS.agentBrowser}`);
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
const title = agentBrowserJson('get title');
|
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
|
// Test 2: Element selection
|
||||||
|
|||||||
+3
-1
@@ -485,16 +485,18 @@ program
|
|||||||
.description('Start the web interface')
|
.description('Start the web interface')
|
||||||
.option('-p, --port <port>', 'Port to listen on', '3000')
|
.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('--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) => {
|
.action(async (options) => {
|
||||||
const { startWebServer } = await import('./web/server.js');
|
const { startWebServer } = await import('./web/server.js');
|
||||||
const port = parseInt(options.port, 10);
|
const port = parseInt(options.port, 10);
|
||||||
const https = !!options.https;
|
const https = !!options.https;
|
||||||
|
const titleHostname = options.titleHostname;
|
||||||
const protocol = https ? 'https' : 'http';
|
const protocol = https ? 'https' : 'http';
|
||||||
|
|
||||||
console.log(chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`));
|
console.log(chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`));
|
||||||
|
|
||||||
try {
|
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}`));
|
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://localhost:${port}`));
|
||||||
if (https) {
|
if (https) {
|
||||||
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
|
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
|
||||||
|
|||||||
@@ -1844,6 +1844,8 @@ class CodemanApp {
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
renderSessionTabs() {
|
renderSessionTabs() {
|
||||||
|
// Don't re-render while user is typing in the inline rename input
|
||||||
|
if (this._inlineRenameActive) return;
|
||||||
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
|
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1988,6 +1990,7 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_fullRenderSessionTabs() {
|
_fullRenderSessionTabs() {
|
||||||
|
if (this._inlineRenameActive) return;
|
||||||
const container = this.$('sessionTabs');
|
const container = this.$('sessionTabs');
|
||||||
|
|
||||||
// Clean up any orphaned dropdowns before re-rendering
|
// Clean up any orphaned dropdowns before re-rendering
|
||||||
|
|||||||
@@ -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 AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
|
||||||
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
||||||
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
|
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 SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
||||||
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
|
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
|
||||||
|
|
||||||
|
|||||||
@@ -291,11 +291,11 @@ class NotificationManager {
|
|||||||
this.titleFlashInterval = setInterval(() => {
|
this.titleFlashInterval = setInterval(() => {
|
||||||
this.titleFlashState = !this.titleFlashState;
|
this.titleFlashState = !this.titleFlashState;
|
||||||
document.title = this.titleFlashState
|
document.title = this.titleFlashState
|
||||||
? `\u26A0\uFE0F (${this.unreadCount}) Codeman`
|
? `\u26A0\uFE0F (${this.unreadCount}) ${this.originalTitle}`
|
||||||
: this.originalTitle;
|
: this.originalTitle;
|
||||||
}, TITLE_FLASH_INTERVAL_MS);
|
}, TITLE_FLASH_INTERVAL_MS);
|
||||||
// Set immediately
|
// Set immediately
|
||||||
document.title = `\u26A0\uFE0F (${this.unreadCount}) Codeman`;
|
document.title = `\u26A0\uFE0F (${this.unreadCount}) ${this.originalTitle}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -912,11 +912,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
|
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
|
||||||
if (!tabName) return;
|
if (!tabName) return;
|
||||||
|
|
||||||
|
// Prevent tab re-renders from destroying the input while renaming
|
||||||
|
this._inlineRenameActive = true;
|
||||||
|
|
||||||
const currentName = this.getSessionName(session);
|
const currentName = this.getSessionName(session);
|
||||||
const parsed = parseSessionPrefix(session.name);
|
const parsed = parseSessionPrefix(session.name);
|
||||||
const originalContent = tabName.textContent;
|
const originalContent = tabName.textContent;
|
||||||
|
// Clear existing content to make room for the input element
|
||||||
tabName.textContent = '';
|
tabName.textContent = '';
|
||||||
tabName.innerHTML = '';
|
while (tabName.firstChild) tabName.removeChild(tabName.firstChild);
|
||||||
|
|
||||||
// If prefix detected, show it as non-editable label
|
// If prefix detected, show it as non-editable label
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
@@ -938,6 +942,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
input.select();
|
input.select();
|
||||||
|
|
||||||
const finishRename = async () => {
|
const finishRename = async () => {
|
||||||
|
if (!this._inlineRenameActive) return; // prevent double-fire
|
||||||
|
this._inlineRenameActive = false;
|
||||||
const suffix = input.value.trim();
|
const suffix = input.value.trim();
|
||||||
let fullName;
|
let fullName;
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
@@ -959,6 +965,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.showToast('Failed to rename', 'error');
|
this.showToast('Failed to rename', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Re-render tabs to restore full tab structure
|
||||||
|
this.renderSessionTabs();
|
||||||
};
|
};
|
||||||
|
|
||||||
input.addEventListener('blur', finishRename);
|
input.addEventListener('blur', finishRename);
|
||||||
|
|||||||
@@ -66,48 +66,60 @@ const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer.
|
* 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)
|
* Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA) to animate
|
||||||
* to animate the spinner and update the status bar. During long thinking phases, these frames
|
* 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,
|
* accumulate to 500KB+ of repeated overwrites to the same rows.
|
||||||
* only spinner frames are returned, making the terminal appear empty.
|
|
||||||
*
|
*
|
||||||
* Strategy: find where absolute-positioned redraws begin (first VPA sequence), then keep
|
* Strategy: detect "redraw clusters" — dense runs of VPA escapes where each is within
|
||||||
* only the last ~4KB of redraw frames (the final visual state) and discard the rest.
|
* 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 {
|
function stripInkRedrawBloat(buffer: string): string {
|
||||||
// Find where Ink's absolute-positioned redraws start (first CSI n d = VPA)
|
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
const firstVPA = buffer.search(/\x1b\[\d+d/);
|
const vpaRe = /\x1b\[\d+d/g;
|
||||||
if (firstVPA === -1) return buffer; // No Ink redraws
|
const positions: number[] = [];
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
const contentPart = buffer.slice(0, firstVPA);
|
while ((m = vpaRe.exec(buffer)) !== null) {
|
||||||
const redrawPart = buffer.slice(firstVPA);
|
positions.push(m.index);
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
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(
|
export function registerSessionRoutes(
|
||||||
|
|||||||
+28
-4
@@ -37,7 +37,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { existsSync, mkdirSync, readFileSync, chmodSync } from 'node:fs';
|
import { existsSync, mkdirSync, readFileSync, chmodSync } from 'node:fs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { execSync } from 'node:child_process';
|
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 { EventEmitter } from 'node:events';
|
||||||
import { Session, type BackgroundTask } from '../session.js';
|
import { Session, type BackgroundTask } from '../session.js';
|
||||||
import type { ClaudeMode, SessionState } from '../types.js';
|
import type { ClaudeMode, SessionState } from '../types.js';
|
||||||
@@ -119,6 +119,10 @@ import {
|
|||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
function escapeHtmlText(value: string): string {
|
||||||
|
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||||
|
}
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SESSIONS_LIST_CACHE_TTL,
|
SESSIONS_LIST_CACHE_TTL,
|
||||||
SCHEDULED_CLEANUP_INTERVAL,
|
SCHEDULED_CLEANUP_INTERVAL,
|
||||||
@@ -226,12 +230,18 @@ export class WebServer extends EventEmitter {
|
|||||||
teamRemoved: (config: unknown) => void;
|
teamRemoved: (config: unknown) => void;
|
||||||
taskUpdated: (data: unknown) => void;
|
taskUpdated: (data: unknown) => void;
|
||||||
} | null = null;
|
} | 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();
|
super();
|
||||||
this.setMaxListeners(0);
|
this.setMaxListeners(0);
|
||||||
this.port = port;
|
this.port = port;
|
||||||
this.https = https;
|
this.https = https;
|
||||||
this.testMode = testMode;
|
this.testMode = testMode;
|
||||||
|
this.titleHostname = titleHostname || getHostname();
|
||||||
|
this.windowTitle = `codeman:${this.titleHostname}`;
|
||||||
|
this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8');
|
||||||
|
|
||||||
if (https) {
|
if (https) {
|
||||||
const { key, cert } = getOrCreateSelfSignedCert();
|
const { key, cert } = getOrCreateSelfSignedCert();
|
||||||
@@ -526,6 +536,12 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
// Security headers + CORS
|
// Security headers + CORS
|
||||||
registerSecurityHeaders(this.app, this.https);
|
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
|
// Service worker must never be cached — browsers check for SW updates on navigation
|
||||||
this.app.get('/sw.js', async (_req, reply) => {
|
this.app.get('/sw.js', async (_req, reply) => {
|
||||||
return reply
|
return reply
|
||||||
@@ -922,6 +938,13 @@ export class WebServer extends EventEmitter {
|
|||||||
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
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> {
|
private async setupSessionListeners(session: Session): Promise<void> {
|
||||||
// Create run summary tracker for this session
|
// Create run summary tracker for this session
|
||||||
const summaryTracker = new RunSummaryTracker(session.id, session.name);
|
const summaryTracker = new RunSummaryTracker(session.id, session.name);
|
||||||
@@ -1970,9 +1993,10 @@ export class WebServer extends EventEmitter {
|
|||||||
export async function startWebServer(
|
export async function startWebServer(
|
||||||
port: number = 3000,
|
port: number = 3000,
|
||||||
https: boolean = false,
|
https: boolean = false,
|
||||||
testMode: boolean = false
|
testMode: boolean = false,
|
||||||
|
titleHostname?: string
|
||||||
): Promise<WebServer> {
|
): Promise<WebServer> {
|
||||||
const server = new WebServer(port, https, testMode);
|
const server = new WebServer(port, https, testMode, titleHostname);
|
||||||
await server.start();
|
await server.start();
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
@@ -49,8 +49,10 @@ async function waitForElement(selector: string, timeout = 10000): Promise<boolea
|
|||||||
try {
|
try {
|
||||||
const count = browserJson<{ count: number }>(`get count "${selector}"`);
|
const count = browserJson<{ count: number }>(`get count "${selector}"`);
|
||||||
if (count.count > 0) return true;
|
if (count.count > 0) return true;
|
||||||
} catch { /* retry */ }
|
} catch {
|
||||||
await new Promise(r => setTimeout(r, 500));
|
/* retry */
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -74,7 +76,9 @@ function isVisible(selector: string): boolean {
|
|||||||
function closeBrowser() {
|
function closeBrowser() {
|
||||||
try {
|
try {
|
||||||
browser('close');
|
browser('close');
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('File Link Click Tests', () => {
|
describe('File Link Click Tests', () => {
|
||||||
@@ -95,14 +99,14 @@ describe('File Link Click Tests', () => {
|
|||||||
|
|
||||||
server = new WebServer(TEST_PORT, false, true);
|
server = new WebServer(TEST_PORT, false, true);
|
||||||
await server.start();
|
await server.start();
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
|
||||||
// Test if browser is available
|
// Test if browser is available
|
||||||
try {
|
try {
|
||||||
browser(`open ${baseUrl}`);
|
browser(`open ${baseUrl}`);
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
const title = browserJson<{ title: string }>('get title');
|
const title = browserJson<{ title: string }>('get title');
|
||||||
browserAvailable = title.title === 'Codeman';
|
browserAvailable = title.title.startsWith('codeman:');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Browser not available, skipping browser tests:', (e as Error).message);
|
console.warn('Browser not available, skipping browser tests:', (e as Error).message);
|
||||||
browserAvailable = false;
|
browserAvailable = false;
|
||||||
@@ -114,14 +118,18 @@ describe('File Link Click Tests', () => {
|
|||||||
for (const sessionId of createdSessions) {
|
for (const sessionId of createdSessions) {
|
||||||
try {
|
try {
|
||||||
await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
|
await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await server.stop();
|
await server.stop();
|
||||||
|
|
||||||
// Cleanup test directory
|
// Cleanup test directory
|
||||||
try {
|
try {
|
||||||
rmSync(testDir, { recursive: true, force: true });
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
it('should create shell session and display terminal output', async () => {
|
it('should create shell session and display terminal output', async () => {
|
||||||
@@ -142,7 +150,7 @@ describe('File Link Click Tests', () => {
|
|||||||
createdSessions.push(data.session.id);
|
createdSessions.push(data.session.id);
|
||||||
|
|
||||||
// Wait for session to appear in UI
|
// 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
|
// Check that terminal is visible
|
||||||
const terminalExists = await waitForElement('.xterm-screen', 5000);
|
const terminalExists = await waitForElement('.xterm-screen', 5000);
|
||||||
@@ -167,7 +175,7 @@ describe('File Link Click Tests', () => {
|
|||||||
body: JSON.stringify({ input: command + '\r' }),
|
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
|
// Check if xterm contains the file path
|
||||||
// The xterm link provider should detect "tail -f /path/to/file" pattern
|
// 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
|
// Click somewhere in the terminal where the tail -f line should be
|
||||||
// This is approximate - the link detection works on hover
|
// This is approximate - the link detection works on hover
|
||||||
browser('click ".xterm-screen"');
|
browser('click ".xterm-screen"');
|
||||||
await new Promise(r => setTimeout(r, 500));
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Click failed:', e);
|
console.log('Click failed:', e);
|
||||||
}
|
}
|
||||||
@@ -243,10 +251,10 @@ describe('File Link Click Tests', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ input: `echo "${pattern}"\r` }),
|
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
|
// Verify patterns appear in terminal
|
||||||
const terminalText = getText('.xterm-screen');
|
const terminalText = getText('.xterm-screen');
|
||||||
@@ -255,11 +263,12 @@ describe('File Link Click Tests', () => {
|
|||||||
}
|
}
|
||||||
}, 60000);
|
}, 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
|
// Unit test for pattern matching logic - runs without browser
|
||||||
// Pattern matches: tail -f /path, grep pattern /path, cat -n /path
|
// 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 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;
|
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||||||
|
|
||||||
// Test cmdPattern
|
// Test cmdPattern
|
||||||
@@ -309,13 +318,14 @@ it('should match file paths with various command patterns', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT match invalid or unsafe paths', () => {
|
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 = [
|
const invalidCases = [
|
||||||
'This is just text without paths',
|
'This is just text without paths',
|
||||||
'./relative/path.log', // relative path
|
'./relative/path.log', // relative path
|
||||||
'C:\\Windows\\path.log', // windows path
|
'C:\\Windows\\path.log', // windows path
|
||||||
'/usr/bin/something.log', // /usr not in allowed prefixes
|
'/usr/bin/something.log', // /usr not in allowed prefixes
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const line of invalidCases) {
|
for (const line of invalidCases) {
|
||||||
@@ -337,7 +347,7 @@ it('should match file paths with various command patterns', () => {
|
|||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expect(data.success).toBe(true);
|
expect(data.success).toBe(true);
|
||||||
sessionId = data.sessionId; // quick-start returns sessionId directly
|
sessionId = data.sessionId; // quick-start returns sessionId directly
|
||||||
createdSessions.push(sessionId);
|
createdSessions.push(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user