Compare commits

...
Author SHA1 Message Date
arkonandClaude Opus 4.6 e96baf9f66 chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 01:42:16 +01:00
arkon 7b8aa529f2 chore: version packages 2026-03-15 03:52:30 +01:00
arkonandClaude Opus 4.6 0ad4e0ea24 fix: correct resolveCasePath priority order and suppress JSON parse warnings
- resolveCasePath now checks linked cases first (matching original behavior
  of /api/cases/:name and /api/cases/:name/fix-plan handlers)
- readLinkedCases only warns on real I/O errors, not JSON parse errors
  (SyntaxError has no .code property, so check for .code existence first)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 03:50:45 +01:00
arkonandClaude Opus 4.6 6bc403d88d refactor: clean up case routes DRY violations, remove dead export, standardize reply API
- Extract readLinkedCases() helper and resolveCasePath() to eliminate 6x duplicated
  linked-cases.json path construction and 5x duplicated file read/parse logic
- Replace O(n) .some() duplicate check with O(1) Set.has() in case listing
- Un-export isError() in types/api.ts (only used internally by getErrorMessage)
- Standardize reply.status() → reply.code() in system-routes (Fastify canonical API)
- Update CLAUDE.md: accurate frontend module listing, SSE event count (~106)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 03:47:18 +01:00
7 changed files with 77 additions and 97 deletions
+16
View File
@@ -1,5 +1,21 @@
# aicodeman
## 0.4.4
### Patch Changes
- fix: mobile keyboard hides terminal content on iPhone
Fixed a bug where opening the virtual keyboard on iPhone left zero visible terminal space. Two independent mechanisms were both accounting for the keyboard height: `MobileDetection.updateAppHeight()` shrunk `--app-height` to the visual viewport height, while `KeyboardHandler.updateLayoutForKeyboard()` added a large `paddingBottom`. These double-counted, leaving negative space for the terminal (user saw accessory bar + toolbar but no terminal content).
Fix: `updateAppHeight()` now skips when the keyboard is visible, and `handleViewportResize()` restores `--app-height` to the pre-keyboard value on first detection (since MobileDetection's listener fires before KeyboardHandler's). On keyboard close, `--app-height` is re-synced to the current visual viewport.
## 0.4.3
### Patch Changes
- Refactor case routes: extract readLinkedCases() and resolveCasePath() helpers to eliminate 6x duplicated linked-cases.json path construction and 5x duplicated file read/parse logic. Replace O(n) .some() duplicate check with O(1) Set.has() in case listing. Un-export unused isError() type guard. Standardize reply.status() to reply.code() in system routes. Update CLAUDE.md frontend module listing and SSE event count.
## 0.4.2
### 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.2 (must match `package.json`)
**Version**: 0.4.4 (must match `package.json`)
## Project Overview
@@ -109,7 +109,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **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` (13 route modules incl. `ws-routes.ts` + barrel), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Frontend** | `src/web/public/app.js` (~2.6K lines, core) + 6 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 existing modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `sw.js`, `input-cjk.js`) | |
| **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`) + 6 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-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` → 13 domain files | See `@fileoverview` in index.ts |
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
@@ -166,7 +166,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### SSE Event Registry
~100 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
~106 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
### API Routes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.4.2",
"version": "0.4.4",
"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",
+1 -1
View File
@@ -117,7 +117,7 @@ export interface CaseInfo {
* @param value The value to check
* @returns True if the value is an Error instance
*/
export function isError(value: unknown): value is Error {
function isError(value: unknown): value is Error {
return value instanceof Error;
}
+12 -1
View File
@@ -100,8 +100,12 @@ const MobileDetection = {
/** Set --app-height CSS variable from visual viewport.
* On iPad Safari with tabs, 100vh extends behind the tab bar.
* visualViewport.height reflects the actual visible area. */
* visualViewport.height reflects the actual visible area.
* Skips when virtual keyboard is open — KeyboardHandler manages
* layout via translateY + paddingBottom; shrinking --app-height
* would double-count and leave zero space for the terminal. */
updateAppHeight() {
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) return;
const vh = window.visualViewport?.height || window.innerHeight;
document.documentElement.style.setProperty('--app-height', `${vh}px`);
},
@@ -225,6 +229,10 @@ const KeyboardHandler = {
if (heightDiff > 150 && !this.keyboardVisible) {
this.keyboardVisible = true;
document.body.classList.add('keyboard-visible');
// Restore --app-height: MobileDetection's resize listener fires before ours
// and may have already shrunk it for the keyboard viewport change.
// Use initialViewportHeight (captured before keyboard opened).
document.documentElement.style.setProperty('--app-height', `${this.initialViewportHeight}px`);
this.onKeyboardShow();
}
// Keyboard hidden (viewport grew back close to initial)
@@ -234,6 +242,9 @@ const KeyboardHandler = {
this.keyboardVisible = false;
document.body.classList.remove('keyboard-visible');
this.onKeyboardHide();
// Re-sync --app-height now that keyboard is gone (MobileDetection skipped
// updates while keyboardVisible was true)
MobileDetection.updateAppHeight();
}
// Update baseline when keyboard is not visible — adapts to address bar
+41 -88
View File
@@ -18,6 +18,28 @@ import { CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort } from '../ports/index.js';
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
async function readLinkedCases(): Promise<Record<string, string>> {
try {
return JSON.parse(await fs.readFile(LINKED_CASES_FILE, 'utf-8'));
} catch (err) {
// Only warn on real I/O errors, not ENOENT (file missing) or SyntaxError (corrupted JSON)
if ((err as NodeJS.ErrnoException).code && (err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn('[Server] Failed to read linked cases:', err);
}
return {};
}
}
/** Resolve a case name to its directory path, checking linked cases first, then CASES_DIR. */
async function resolveCasePath(name: string): Promise<string> {
const linkedCases = await readLinkedCases();
if (linkedCases[name]) return linkedCases[name];
return join(CASES_DIR, name);
}
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
// ═══════════════════════════════════════════════════════════════
// Case CRUD (list, create, link, detail, fix-plan)
@@ -45,22 +67,15 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
// Get linked cases
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
try {
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
for (const [name, path] of Object.entries(linkedCases)) {
// Only add if not already in cases (avoid duplicates) and path exists
if (!cases.some((c) => c.name === name) && existsSync(path)) {
cases.push({
name,
path,
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
});
}
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn('[Server] Failed to read linked cases:', err);
const linkedCases = await readLinkedCases();
const existingNames = new Set(cases.map((c) => c.name));
for (const [name, path] of Object.entries(linkedCases)) {
if (!existingNames.has(name) && existsSync(path)) {
cases.push({
name,
path,
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
});
}
}
@@ -126,15 +141,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
// Load existing linked cases
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
let linkedCases: Record<string, string> = {};
try {
linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn('[Server] Failed to read linked cases:', err);
}
}
const linkedCases = await readLinkedCases();
// Check if name is already linked
if (linkedCases[name]) {
@@ -151,7 +158,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
if (!existsSync(codemanDir)) {
mkdirSync(codemanDir, { recursive: true });
}
await fs.writeFile(linkedCasesFile, JSON.stringify(linkedCases, null, 2));
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
ctx.broadcast(SseEvent.CaseLinked, { name, path: expandedPath });
return { success: true, data: { case: { name, path: expandedPath } } };
} catch (err) {
@@ -166,34 +173,18 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
// First check linked cases
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
try {
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
if (linkedCases[name]) {
const linkedPath = linkedCases[name];
return {
name,
path: linkedPath,
hasClaudeMd: existsSync(join(linkedPath, 'CLAUDE.md')),
linked: true,
};
}
} catch {
// ENOENT or parse errors - fall through to CASES_DIR check
}
// Then check CASES_DIR
const casePath = join(CASES_DIR, name);
const casePath = await resolveCasePath(name);
if (!existsSync(casePath)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
}
const linked = casePath !== join(CASES_DIR, name);
return {
name,
path: casePath,
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
...(linked && { linked: true }),
};
});
@@ -206,21 +197,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
// Get case path (check linked cases first, then CASES_DIR)
let casePath: string | null = null;
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
try {
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
if (linkedCases[name]) {
casePath = linkedCases[name];
}
} catch {
// ENOENT or parse errors - fall through to CASES_DIR
}
if (!casePath) {
casePath = join(CASES_DIR, name);
}
const casePath = await resolveCasePath(name);
const fixPlanPath = join(casePath, '@fix_plan.md');
@@ -321,23 +298,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
const { caseName } = req.params as { caseName: string };
let casePath = validatePathWithinBase(caseName, CASES_DIR);
if (!casePath) {
if (!validatePathWithinBase(caseName, CASES_DIR)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
// Check linked cases if path doesn't exist
if (!existsSync(casePath)) {
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
try {
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
if (linkedCases[caseName]) {
casePath = linkedCases[caseName];
}
} catch {
// No linked cases file
}
}
const casePath = await resolveCasePath(caseName);
const wizardDir = join(casePath, 'ralph-wizard');
@@ -376,8 +341,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// Cache disabled to ensure fresh prompts when starting new plan generations
app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
const { caseName, filePath } = req.params as { caseName: string; filePath: string };
let casePath = validatePathWithinBase(caseName, CASES_DIR);
if (!casePath) {
if (!validatePathWithinBase(caseName, CASES_DIR)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
@@ -386,18 +350,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
reply.header('Pragma', 'no-cache');
reply.header('Expires', '0');
// Check linked cases if path doesn't exist
if (!existsSync(casePath)) {
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
try {
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
if (linkedCases[caseName]) {
casePath = linkedCases[caseName];
}
} catch {
// No linked cases file
}
}
const casePath = await resolveCasePath(caseName);
const wizardDir = join(casePath, 'ralph-wizard');
+3 -3
View File
@@ -709,7 +709,7 @@ export function registerSystemRoutes(
for await (const chunk of req.raw) {
totalSize += chunk.length;
if (totalSize > MAX_SCREENSHOT_SIZE) {
reply.status(413);
reply.code(413);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File too large (max 10MB)');
}
chunks.push(chunk as Buffer);
@@ -794,12 +794,12 @@ export function registerSystemRoutes(
const { name } = req.params as { name: string };
// Prevent path traversal
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
reply.status(400);
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid filename');
}
const filepath = join(SCREENSHOTS_DIR, name);
if (!existsSync(filepath)) {
reply.status(404);
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Screenshot not found');
}
const ext = name.match(/\.(png|jpg|jpeg|webp|gif)$/i)?.[1]?.toLowerCase() ?? 'png';