Compare commits

...
Author SHA1 Message Date
arkonandClaude Opus 4.6 84137cdba4 chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:59:27 +01:00
arkonandClaude Opus 4.6 6eb3969816 fix: avoid no-control-regex lint error for ANSI strip pattern
Use RegExp constructor with String.raw to express \x1b without
a literal control character in the source, matching the pattern
used elsewhere in the codebase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:57:36 +01:00
arkonandClaude Opus 4.6 de49437a6f docs: add browser-testing-guide to CLAUDE.md references, clarify route count
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:38:14 +01:00
arkonandClaude Opus 4.6 6a27639083 fix: increase Ink frame search window from 4KB to 64KB to prevent partial frames
Single Ink frames with response content can be 10-20KB, so the 4KB tail
was too small and caused blank gaps. Now searches the last 64KB for VPA
row drops to find the last complete frame boundary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:38:09 +01:00
arkonandClaude Opus 4.6 eb1b38c718 fix: align case select group height — stretch buttons to match dropdown
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:06:15 +01:00
arkonandClaude Opus 4.6 ea7b103b47 fix: prevent stale terminal data on tab switch — add chunkedTerminalWrite cancellation
chunkedTerminalWrite used requestAnimationFrame to write buffer chunks across
frames but had no cancellation. When switching tabs, old session's remaining
chunks continued writing stale data into the new session's terminal, causing
visual artifacts and garbled content.

- Add _chunkedWriteGen generation counter to abort in-flight chunked writes
- Bump gen early in selectSession() and SSE reconnect to immediately cancel
- Guard finish() so aborted writes don't flush SSE queue for wrong session
- Add fitAddon.fit() before buffer writes to sync terminal dimensions
- Add fitAddon.fit() in sendResize() to ensure local/server dim parity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 03:00:58 +01:00
arkonandClaude Opus 4.6 bec8e2f9ee fix: improve history prompt extraction — filter expanded commands, add tail scan fallback
Skip /init expansions, slash commands, orchestrator prompts, ANSI codes, secrets,
and short/vague messages. When head scan finds no usable prompt (e.g. /init sessions),
read last 32KB of transcript to find a recent meaningful user message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 02:42:50 +01:00
arkonandClaude Opus 4.6 2203f3a347 feat: visual redesign — glass morphism, refined colors, polished UI
Modernize the entire UI with a cohesive "refined dark glass" aesthetic
while preserving all existing functionality.

- Header/toolbar: backdrop-filter blur(16px), semi-transparent backgrounds
- Buttons: 6px radius, multi-stop gradients, inner glow, cubic-bezier transitions
- Welcome screen: gradient text title, radial bg glow, pill-shaped buttons with hover lift
- Panels/modals: glass backgrounds, 12px radius, layered shadows
- Color palette: cooler blue-tinted darks replacing flat blacks
- Forms: refined inputs with focus rings, glass toggle switches
- New CSS vars: --glass-bg, --glass-border, --btn-radius, --transition-smooth

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 02:24:27 +01:00
arkonandClaude Opus 4.6 867a10d78a refactor: optimize history endpoint — reuse buffer, extract readFileHead, use line iterator
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 02:01:56 +01:00
8 changed files with 455 additions and 275 deletions
+6
View File
@@ -1,5 +1,11 @@
# aicodeman
## 0.5.0
### Minor Changes
- Visual redesign with glass morphism, refined colors, and polished UI. Optimize history endpoint with buffer reuse and line iterator. Fix Ink frame search window (4KB→64KB) to prevent partial frames. Fix stale terminal data on tab switch via chunkedTerminalWrite cancellation. Improve history prompt extraction with expanded command filtering and tail scan fallback. Align case select group height to match dropdown. Fix no-control-regex lint error for ANSI strip pattern. Add browser-testing-guide to CLAUDE.md references.
## 0.4.7
### Patch Changes
+3 -3
View File
@@ -52,7 +52,7 @@ When user says "COM":
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
**Version**: 0.4.7 (must match `package.json`)
**Version**: 0.5.0 (must match `package.json`)
## Project Overview
@@ -109,7 +109,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | |
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (14 route modules incl. `ws-routes.ts` + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (14 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Frontend** | `src/web/public/app.js` (~2.6K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
| **Types** | `src/types/index.ts` → 15 domain files | See `@fileoverview` in index.ts |
@@ -226,7 +226,7 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina
## References
Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`, `orchestrator-loop-architecture.md`. Agent Teams: `agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`.
Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`, `orchestrator-loop-architecture.md`, `browser-testing-guide.md`. Agent Teams: `agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`.
## Scripts
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.4.7",
"version": "0.5.0",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+11
View File
@@ -1362,6 +1362,8 @@ class CodemanApp {
this.writeFrameScheduled = false;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
// Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers)
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
// Preserve local echo overlay text across SSE reconnect — just hide until
// terminal buffer reloads and prompt is visible again. _render() re-scans
// for the ❯ prompt on every call, so rerender() after buffer load repositions it.
@@ -2020,6 +2022,10 @@ class CodemanApp {
this.writeFrameScheduled = false;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
// Abort any in-flight chunkedTerminalWrite from the previous session.
// Without this, old rAF-scheduled chunks continue writing stale data
// into the terminal, interleaving with the new session's buffer.
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
// End any in-flight IME composition.
// iOS Safari keeps autocorrect composing; switching tabs without ending it
// leaves xterm's _compositionHelper._isComposing stuck true, which blocks
@@ -2136,6 +2142,11 @@ class CodemanApp {
this._isLoadingBuffer = true;
this._loadBufferQueue = [];
try {
// Fit terminal to container BEFORE writing any buffer data.
// If the browser was resized while viewing another session, the terminal
// canvas may be at stale dimensions — content would render at wrong width.
if (this.fitAddon) this.fitAddon.fit();
// Instant cache restore — show previous buffer via chunked write to avoid WebGL GPU stalls.
// Direct terminal.write() of large cached buffers (256KB+) can block the main thread
// for 5+ seconds while the WebGL renderer processes ReadPixels synchronously.
+5 -5
View File
@@ -30,13 +30,13 @@
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#0a0a0a}
.skeleton-header{height:40px;background:#111;border-bottom:1px solid #1a1a2e;display:flex;align-items:center;padding:0 12px}
.skeleton-brand{color:#60a5fa;font-size:14px;font-weight:600;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.7}
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#09090b}
.skeleton-header{height:40px;background:rgba(19,19,22,0.85);border-bottom:1px solid rgba(255,255,255,0.06);display:flex;align-items:center;padding:0 12px}
.skeleton-brand{color:#60a5fa;font-size:14px;font-weight:700;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.7}
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
.skeleton-tab{width:80px;height:24px;background:#1a1a2e;border-radius:4px}
.skeleton-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px}
.skeleton-terminal{flex:1;background:#0d0d0d}
.skeleton-toolbar{height:32px;background:#111;border-top:1px solid #1a1a2e}
.skeleton-toolbar{height:42px;background:rgba(19,19,22,0.85);border-top:1px solid rgba(255,255,255,0.06)}
.app-loaded .loading-skeleton{display:none}
</style>
</head>
+326 -236
View File
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -219,6 +219,9 @@ Object.assign(CodemanApp.prototype, {
// Welcome message
this.showWelcome();
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
this._chunkedWriteGen = 0;
// Handle resize with throttling for performance
this._resizeTimeout = null;
this._lastResizeDims = null;
@@ -1033,6 +1036,10 @@ Object.assign(CodemanApp.prototype, {
* @returns {Promise<void>} - Resolves when all chunks written
*/
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
// older writes abort instead of continuing to push stale data into the terminal.
const writeGen = ++this._chunkedWriteGen;
return new Promise((resolve) => {
if (!buffer || buffer.length === 0) {
this._finishBufferLoad();
@@ -1049,7 +1056,10 @@ Object.assign(CodemanApp.prototype, {
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
const finish = () => {
this._finishBufferLoad();
// Only finish if we're still the active write — a newer write owns buffer load state
if (this._chunkedWriteGen === writeGen) {
this._finishBufferLoad();
}
resolve();
};
@@ -1067,6 +1077,12 @@ Object.assign(CodemanApp.prototype, {
const _chunkStart = performance.now();
let _chunkCount = 0;
const writeChunk = () => {
// Abort if a newer chunked write started (user switched tabs)
if (this._chunkedWriteGen !== writeGen) {
resolve();
return;
}
if (offset >= cleanBuffer.length) {
const _totalMs = performance.now() - _chunkStart;
console.log(`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`);
@@ -1240,6 +1256,9 @@ Object.assign(CodemanApp.prototype, {
* @returns {Promise<void>}
*/
async sendResize(sessionId) {
// Fit terminal to container before reading dimensions — ensures local
// terminal size matches what we report to the server PTY.
if (this.fitAddon) this.fitAddon.fit();
const dims = this.getTerminalDimensions();
if (!dims) return;
// Fast path: WebSocket resize
+83 -29
View File
@@ -73,15 +73,28 @@ function stripInkRedrawBloat(buffer: string): string {
// If the redraw section is small (<16KB), not worth stripping
if (redrawPart.length < 16384) return buffer;
// Keep only the last 4KB of redraw frames — this preserves the final visual state
// (spinner position, status bar text, token count, etc.)
const tail = redrawPart.slice(-4096);
// Avoid starting mid-escape: find first complete frame boundary
// eslint-disable-next-line no-control-regex
const frameStart = tail.search(/\x1b\(B\x1b\[m|\x1b\[\d+d|\x1b\[\d+;\d+H/);
const cleanTail = frameStart > 0 ? tail.slice(frameStart) : tail;
// 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);
return contentPart + cleanTail;
// 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;
}
return contentPart + searchWindow.slice(lastFrameStart);
}
export function registerSessionRoutes(
@@ -965,7 +978,12 @@ export function registerSessionRoutes(
/** Extract the text of the first user message from a JSONL transcript head. */
function extractFirstUserPrompt(head: string): string | undefined {
const MAX_PROMPT_LEN = 120;
for (const line of head.split('\n')) {
// Iterate lines without allocating a full split array
let start = 0;
while (start < head.length) {
const end = head.indexOf('\n', start);
const line = end === -1 ? head.slice(start) : head.slice(start, end);
start = end === -1 ? head.length : end + 1;
if (!line.includes('"type":"user"')) continue;
try {
const entry = JSON.parse(line);
@@ -979,14 +997,25 @@ export function registerSessionRoutes(
if (textBlock) text = textBlock.text;
}
if (!text) continue;
// Strip XML-like system/command tags that Claude Code injects into transcripts
// Strip XML-like system/command tags and ANSI escapes from transcripts
text = text
.replace(/<[^>]+>/g, '')
.replace(new RegExp(String.raw`\x1b\[[0-9;]*[a-zA-Z]`, 'g'), '')
.trim()
.replace(/\s+/g, ' ');
if (!text) continue;
// Skip system-injected messages and slash command artifacts (not real user prompts)
if (/^(Caveat:|init\b|clear\b|\/\w+ \w+$|You are a )/i.test(text)) continue;
// Skip system-injected messages, slash command artifacts, and expanded skill prompts
if (
/^(Caveat:|init\b|clear\b|resume\b|\/[a-z][\w-]*\b|You are a |\[Request |Set model to )/i.test(text) ||
/^(Please )?(analyze|review) this codebase/i.test(text) ||
/^(Read|Implement the following) .+, then (search|list|check) /i.test(text) ||
/^\d+ vulnerabilit/i.test(text) ||
/\btoolu_/.test(text) ||
/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+/.test(text) ||
/\b(sk-ant-|ANTHROPIC_API_KEY|API_KEY=|SECRET|TOKEN=)/i.test(text) ||
text.length < 8
)
continue;
return text.length > MAX_PROMPT_LEN ? text.slice(0, MAX_PROMPT_LEN) + '…' : text;
} catch {
// Malformed line — skip
@@ -995,6 +1024,37 @@ export function registerSessionRoutes(
return undefined;
}
/** Read the first 16KB of a file for content sniffing. */
async function readFileHead(path: string, buf: Buffer): Promise<string | null> {
try {
const fd = await fs.open(path, 'r');
const { bytesRead } = await fd.read(buf, 0, buf.length, 0);
await fd.close();
return buf.toString('utf8', 0, bytesRead);
} catch {
return null;
}
}
/** Read the last `buf.length` bytes of a file (for tail-scanning user prompts). */
async function readFileTail(path: string, buf: Buffer, fileSize: number): Promise<string | null> {
try {
const fd = await fs.open(path, 'r');
const offset = Math.max(0, fileSize - buf.length);
const { bytesRead } = await fd.read(buf, 0, buf.length, offset);
await fd.close();
const text = buf.toString('utf8', 0, bytesRead);
// Skip first partial line when we didn't read from the start
if (offset > 0) {
const nl = text.indexOf('\n');
return nl >= 0 ? text.slice(nl + 1) : null;
}
return text;
} catch {
return null;
}
}
app.get('/api/history/sessions', async () => {
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
const results: Array<{
@@ -1005,6 +1065,7 @@ export function registerSessionRoutes(
lastModified: string;
firstPrompt?: string;
}> = [];
const headBuf = Buffer.alloc(16384);
try {
const projectDirs = await fs.readdir(projectsDir);
@@ -1042,20 +1103,9 @@ export function registerSessionRoutes(
// no "user"/"assistant" messages and will fail claude --resume.
// Read first 16KB to check content and extract first user prompt.
let firstPrompt: string | undefined;
const readHead = async (): Promise<string | null> => {
try {
const fd = await fs.open(filePath, 'r');
const buf = Buffer.alloc(16384);
const { bytesRead } = await fd.read(buf, 0, 16384, 0);
await fd.close();
return buf.toString('utf8', 0, bytesRead);
} catch {
return null;
}
};
const head = await readFileHead(filePath, headBuf);
if (fileStat.size < 50000) {
const head = await readHead();
if (
!head ||
(!head.includes('"type":"user"') &&
@@ -1064,11 +1114,15 @@ export function registerSessionRoutes(
) {
continue; // No conversation content — skip
}
firstPrompt = extractFirstUserPrompt(head);
} else {
// Large files are almost certainly real conversations; still read head for prompt
const head = await readHead();
if (head) firstPrompt = extractFirstUserPrompt(head);
}
if (head) firstPrompt = extractFirstUserPrompt(head);
// If head scan found no usable prompt (e.g. session started with /init),
// try reading the tail for a recent user message.
if (!firstPrompt && fileStat.size > 65536) {
const tailBuf = Buffer.alloc(32768);
const tail = await readFileTail(filePath, tailBuf, fileStat.size);
if (tail) firstPrompt = extractFirstUserPrompt(tail);
}
results.push({