Skip to content

Commit 9d9f4d3

Browse files
Ark0Nclaude
andcommitted
chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1ab1dd1 commit 9d9f4d3

13 files changed

Lines changed: 241 additions & 42 deletions

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# codeman
22

3+
## 0.2.7
4+
5+
### Patch Changes
6+
7+
- Fix race condition in StateStore where dirty flag was overwritten after async write, silently discarding mutations
8+
- Fix PlanOrchestrator session leak by adding session.stop() in finally blocks and centralizing cleanup
9+
- Fix symlink path traversal in file-content and file-raw endpoints by adding realpathSync validation
10+
- Fix PTY exit handler to clean up sessionListenerRefs, transcriptWatchers, runSummaryTrackers, and terminal batching state
11+
- Fix sendInput() fire-and-forget by propagating runPrompt errors to task queue via taskError event
12+
- Fix Ralph Loop tick() race condition by running checkTimeouts/assignTasks sequentially with per-iteration error handling
13+
- Fix shell injection in hook scripts by piping HOOK_DATA via printf to curl stdin instead of inline embedding
14+
- Narrow tail-file allowlist to remove ~/.cache and ~/.local/share paths that exposed credentials
15+
- Fix stored XSS in quick-start dropdown by escaping case names with escapeHtml()
16+
317
## 0.2.6
418

519
### Patch Changes

CLAUDE.md

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
1717

1818
**You may be running inside a Codeman-managed tmux session.** Before killing ANY tmux or Claude process:
1919

20-
1. Check: `echo $CODEMAN_TMUX` - if `1`, you're in a managed session
20+
1. Check: `echo $CODEMAN_MUX` - if `1`, you're in a managed session
2121
2. **NEVER** run `tmux kill-session`, `pkill tmux`, or `pkill claude` without confirming
2222
3. Use the web UI or `./scripts/tmux-manager.sh` instead of direct kill commands
2323

@@ -52,7 +52,7 @@ When user says "COM":
5252
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
5353
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
5454
55-
**Version**: 0.2.6 (must match `package.json`)
55+
**Version**: 0.2.7 (must match `package.json`)
5656
5757
## Project Overview
5858
@@ -90,15 +90,18 @@ npx vitest run -t "pattern" # Tests matching name
9090
npm run test:coverage # With coverage report
9191
9292
# Production
93-
npm run build
93+
npm run build # esbuild via scripts/build.mjs (not tsc)
94+
npm run start # node dist/index.js (production)
9495
systemctl --user restart codeman-web
9596
journalctl --user -u codeman-web -f
9697
```
9798
99+
**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, and `format:check` on push to master. Tests are intentionally excluded from CI (they spawn tmux).
100+
98101
## Common Gotchas
99102
100103
- **Single-line prompts only** — `writeViaMux()` sends text and Enter separately; multi-line breaks Ink
101-
- **Don't kill tmux sessions blindly** — Check `$CODEMAN_TMUX` first; you might be inside one
104+
- **Don't kill tmux sessions blindly** — Check `$CODEMAN_MUX` first; you might be inside one
102105
- **Global regex `lastIndex` sharing** — `ANSI_ESCAPE_PATTERN_FULL/SIMPLE` have `g` flag; use `createAnsiPatternFull/Simple()` factory functions for fresh instances in loops
103106
- **DEC 2026 sync blocks** — Never discard incomplete sync blocks (START without END); buffer up to 50ms then flush. See `app.js:extractSyncSegments()`
104107
- **Terminal writes during buffer load** — Live SSE writes are queued while `_isLoadingBuffer` is true to prevent interleaving with historical data
@@ -116,6 +119,7 @@ journalctl --user -u codeman-web -f
116119
117120
| File | Purpose |
118121
|------|---------|
122+
| `src/index.ts` | CLI entry point: global error recovery, uncaught exception guard, `MAX_CONSECUTIVE_ERRORS` auto-restart |
119123
| `src/session.ts` | PTY wrapper: `runPrompt()`, `startInteractive()`, `startShell()` |
120124
| `src/mux-interface.ts` | `TerminalMultiplexer` interface + `MuxSession` type |
121125
| `src/mux-factory.ts` | Create tmux multiplexer instance |
@@ -168,6 +172,24 @@ journalctl --user -u codeman-web -f
168172
| `buffer-limits.ts` | Terminal/text buffer size limits |
169173
| `map-limits.ts` | Global limits for Maps, sessions, watchers |
170174
175+
### Utilities (`src/utils/`)
176+
177+
Re-exported via `src/utils/index.ts`. Key exports:
178+
179+
| File | Exports |
180+
|------|---------|
181+
| `cleanup-manager.ts` | `CleanupManager` — centralized disposal for timers, intervals, watchers, listeners, streams |
182+
| `lru-map.ts` | `LRUMap` — bounded cache with eviction |
183+
| `stale-expiration-map.ts` | `StaleExpirationMap` — TTL-based map with automatic cleanup |
184+
| `regex-patterns.ts` | `ANSI_ESCAPE_PATTERN_FULL/SIMPLE`, `createAnsiPatternFull/Simple()`, `stripAnsi`, `TOKEN_PATTERN`, `SPINNER_PATTERN` |
185+
| `buffer-accumulator.ts` | `BufferAccumulator` — batches rapid writes into single flushes |
186+
| `claude-cli-resolver.ts` | `findClaudeDir`, `getAugmentedPath` — resolves Claude CLI paths |
187+
| `opencode-cli-resolver.ts` | `resolveOpenCodeDir`, `isOpenCodeAvailable`, `getOpenCodeAugmentedPath` — OpenCode CLI support |
188+
| `string-similarity.ts` | `stringSimilarity`, `fuzzyPhraseMatch`, `todoContentHash` |
189+
| `token-validation.ts` | `validateTokenCounts`, `validateTokensAndCost` |
190+
| `nice-wrapper.ts` | `wrapWithNice` — wraps commands with `nice`/`ionice` for lower priority |
191+
| `type-safety.ts` | `assertNever` — exhaustive switch/case guard |
192+
171193
### Data Flow
172194
173195
1. Session spawns `claude --dangerously-skip-permissions` via node-pty
@@ -426,8 +448,20 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base
426448
| **Error codes** | `createErrorResponse()` in `src/types.ts` |
427449
| **Test utilities** | `test/respawn-test-utils.ts` |
428450
| **Mobile test suite** | `mobile-test/README.md` |
429-
430-
Additional design docs, plans, and investigation reports are in the `docs/` directory.
451+
| **OpenCode integration** | `docs/opencode-integration.md` |
452+
| **Local echo overlay** | `docs/local-echo-overlay-plan.md` |
453+
| **Performance investigation** | `docs/performance-investigation-report.md` |
454+
| **First-load optimization** | `docs/first-load-optimization-plan.md`, `docs/perf-audit-first-load.md` |
455+
| **Dead code audit** | `docs/cleanup-findings.md` |
456+
| **TypeScript improvements** | `docs/typescript-improvement-suggestions.md` |
457+
| **Browser testing** | `docs/browser-testing-guide.md` |
458+
| **Mobile testing report** | `docs/mobile-testing-report.md` |
459+
| **Voice input** | `docs/voice-input-plan.md` |
460+
| **Improvement roadmaps** | `docs/respawn-improvement-plan.md`, `docs/ralph-improvement-plan.md`, `docs/plan-improvement-roadmap.md` |
461+
| **Background keystroke forwarding** | `docs/background-keystroke-forwarding-merged-plan.md` |
462+
| **Run summary** | `docs/run-summary-plan.md` |
463+
464+
Additional design docs and investigation reports are in the `docs/` directory.
431465
432466
## Scripts
433467
@@ -437,6 +471,9 @@ Additional design docs, plans, and investigation reports are in the `docs/` dire
437471
| `scripts/monitor-respawn.sh` | Monitor respawn state machine in real-time |
438472
| `scripts/watch-subagents.ts` | Real-time subagent transcript watcher (list, follow by session/agent ID) |
439473
| `scripts/codeman-web.service` | systemd service file for production deployment |
474+
| `scripts/codeman-tunnel.service` | systemd service file for persistent Cloudflare tunnel |
475+
| `scripts/tunnel.sh` | Start/stop/check Cloudflare quick tunnel (`./scripts/tunnel.sh start\|stop\|url`) |
476+
| `scripts/build.mjs` | esbuild-based production build (called by `npm run build`) |
440477
| `scripts/postinstall.js` | npm postinstall hook for setup |
441478
442479
Additional scripts in `scripts/` for screenshots, demos, Ralph wizards, and browser testing.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codeman",
3-
"version": "0.2.6",
3+
"version": "0.2.7",
44
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
55
"type": "module",
66
"main": "dist/index.js",

src/file-stream-manager.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -398,13 +398,7 @@ export class FileStreamManager extends EventEmitter {
398398

399399
// Check if the resolved path is within the working directory
400400
// or common log directories (/tmp intentionally excluded — world-writable)
401-
const allowedPaths = [
402-
normalizedWorkingDir,
403-
'/var/log',
404-
resolve(homedir(), '.local/share'),
405-
resolve(homedir(), '.cache'),
406-
resolve(homedir(), 'logs'),
407-
];
401+
const allowedPaths = [normalizedWorkingDir, '/var/log', resolve(homedir(), 'logs')];
408402

409403
const isAllowed = allowedPaths.some((allowed) => {
410404
const rel = relative(allowed, absolutePath);

src/hooks-config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
2727
// Falls back to empty object if stdin is unavailable or malformed.
2828
const curlCmd = (event: HookEventType) =>
2929
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
30+
`printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` +
3031
`curl -s -X POST "$CODEMAN_API_URL/api/hook-event" ` +
3132
`-H 'Content-Type: application/json' ` +
32-
`-d "{\\"event\\":\\"${event}\\",\\"sessionId\\":\\"$CODEMAN_SESSION_ID\\",\\"data\\":$HOOK_DATA}" ` +
33+
`--data @- ` +
3334
`2>/dev/null || true`;
3435

3536
return {

src/plan-orchestrator.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -444,8 +444,6 @@ export class PlanOrchestrator {
444444
try {
445445
const { result: response } = await session.runPrompt(prompt, { model: this.researchModel });
446446

447-
this.runningSessions.delete(session);
448-
449447
const durationMs = Date.now() - startTime;
450448

451449
// Extract JSON from response
@@ -528,7 +526,6 @@ export class PlanOrchestrator {
528526

529527
return result;
530528
} catch (err) {
531-
this.runningSessions.delete(session);
532529
const durationMs = Date.now() - startTime;
533530
const error = err instanceof Error ? err.message : String(err);
534531
onSubagent?.({
@@ -554,7 +551,10 @@ export class PlanOrchestrator {
554551
durationMs,
555552
};
556553
} finally {
557-
// Always clear the progress interval to prevent memory leaks
554+
// Always clean up session and progress interval — centralizing here
555+
// prevents the race where cancel() and catch both try to manage the set
556+
await session.stop().catch(() => {});
557+
this.runningSessions.delete(session);
558558
clearInterval(progressInterval);
559559
}
560560
}
@@ -617,8 +617,6 @@ export class PlanOrchestrator {
617617
try {
618618
const { result: response } = await session.runPrompt(prompt, { model: this.plannerModel });
619619

620-
this.runningSessions.delete(session);
621-
622620
const durationMs = Date.now() - startTime;
623621

624622
// Extract JSON from response
@@ -670,7 +668,6 @@ export class PlanOrchestrator {
670668

671669
return { success: true, items, gaps, warnings };
672670
} catch (err) {
673-
this.runningSessions.delete(session);
674671
const durationMs = Date.now() - startTime;
675672
const error = err instanceof Error ? err.message : String(err);
676673
onSubagent?.({
@@ -684,7 +681,10 @@ export class PlanOrchestrator {
684681
});
685682
return { success: false, error };
686683
} finally {
687-
// Always clear the progress interval to prevent memory leaks
684+
// Always clean up session and progress interval — centralizing here
685+
// prevents the race where cancel() and catch both try to manage the set
686+
await session.stop().catch(() => {});
687+
this.runningSessions.delete(session);
688688
clearInterval(progressInterval);
689689
}
690690
}

src/ralph-loop.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export class RalphLoop extends EventEmitter {
7777
completion: (sessionId: string, phrase: string) => void;
7878
error: (sessionId: string, error: string) => void;
7979
stopped: (sessionId: string) => void;
80+
taskError: (sessionId: string, taskId: string, error: string) => void;
8081
} | null = null;
8182

8283
constructor(options: RalphLoopOptions = {}) {
@@ -118,11 +119,15 @@ export class RalphLoop extends EventEmitter {
118119
stopped: (sessionId: string) => {
119120
this.handleSessionStopped(sessionId);
120121
},
122+
taskError: (sessionId: string, taskId: string, error: string) => {
123+
this.handleSessionTaskError(sessionId, taskId, error);
124+
},
121125
};
122126

123127
this.sessionManager.on('sessionCompletion', this.sessionEventHandlers.completion);
124128
this.sessionManager.on('sessionError', this.sessionEventHandlers.error);
125129
this.sessionManager.on('sessionStopped', this.sessionEventHandlers.stopped);
130+
this.sessionManager.on('sessionTaskError', this.sessionEventHandlers.taskError);
126131
}
127132

128133
/** Remove event listeners to prevent memory leaks */
@@ -131,6 +136,7 @@ export class RalphLoop extends EventEmitter {
131136
this.sessionManager.off('sessionCompletion', this.sessionEventHandlers.completion);
132137
this.sessionManager.off('sessionError', this.sessionEventHandlers.error);
133138
this.sessionManager.off('sessionStopped', this.sessionEventHandlers.stopped);
139+
this.sessionManager.off('sessionTaskError', this.sessionEventHandlers.taskError);
134140
this.sessionEventHandlers = null;
135141
}
136142
}
@@ -281,8 +287,11 @@ export class RalphLoop extends EventEmitter {
281287
private async tick(): Promise<void> {
282288
this.store.setRalphLoopState({ lastCheckAt: Date.now() });
283289

284-
// Run independent checks in parallel for better performance
285-
await Promise.all([this.checkTimeouts(), this.assignTasks()]);
290+
// Run sequentially: timeouts first so timed-out tasks are cleaned up
291+
// before assignTasks() picks new work (prevents race where both
292+
// mutate the same task concurrently)
293+
await this.checkTimeouts();
294+
await this.assignTasks();
286295

287296
// Check if we should auto-generate tasks (depends on assignment results)
288297
if (this.autoGenerateTasks && this.shouldGenerateTasks()) {
@@ -304,7 +313,11 @@ export class RalphLoop extends EventEmitter {
304313
break;
305314
}
306315

307-
await this.assignTaskToSession(task, session);
316+
try {
317+
await this.assignTaskToSession(task, session);
318+
} catch (err) {
319+
console.error(`[RalphLoop] Failed to assign task ${task.id} to session ${session.id}:`, err);
320+
}
308321
}
309322
}
310323

@@ -400,6 +413,17 @@ export class RalphLoop extends EventEmitter {
400413
}
401414
}
402415

416+
private handleSessionTaskError(_sessionId: string, taskId: string, error: string): void {
417+
const task = this.taskQueue.getTask(taskId);
418+
if (!task) {
419+
return;
420+
}
421+
422+
task.fail(error);
423+
this.taskQueue.updateTask(task);
424+
this.emit('taskFailed', task.id, error);
425+
}
426+
403427
private shouldGenerateTasks(): boolean {
404428
// Generate tasks if:
405429
// 1. No pending tasks

src/session-manager.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ interface SessionHandlers {
5454
error: (data: string) => void;
5555
completion: (phrase: string) => void;
5656
exit: () => void;
57+
taskError: (taskId: string, error: string) => void;
5758
}
5859

5960
export class SessionManager extends EventEmitter {
@@ -134,12 +135,16 @@ export class SessionManager extends EventEmitter {
134135
this.emit('sessionStopped', session.id);
135136
this.updateSessionState(session);
136137
},
138+
taskError: (taskId: string, error: string) => {
139+
this.emit('sessionTaskError', session.id, taskId, error);
140+
},
137141
};
138142

139143
session.on('output', handlers.output);
140144
session.on('error', handlers.error);
141145
session.on('completion', handlers.completion);
142146
session.on('exit', handlers.exit);
147+
session.on('taskError', handlers.taskError);
143148

144149
// Store handlers for later cleanup
145150
this.sessionHandlers.set(session.id, handlers);
@@ -183,6 +188,7 @@ export class SessionManager extends EventEmitter {
183188
session.off('error', handlers.error);
184189
session.off('completion', handlers.completion);
185190
session.off('exit', handlers.exit);
191+
session.off('taskError', handlers.taskError);
186192
this.sessionHandlers.delete(id);
187193
}
188194

src/session.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2203,6 +2203,16 @@ export class Session extends EventEmitter {
22032203
this._lastActivityAt = Date.now();
22042204
this.runPrompt(input).catch((err) => {
22052205
const errorMsg = err instanceof Error ? err.message : String(err);
2206+
// Clean up task state so the task queue doesn't get stuck
2207+
if (this._currentTaskId) {
2208+
const taskId = this._currentTaskId;
2209+
this._currentTaskId = null;
2210+
this._status = 'idle';
2211+
this._lastActivityAt = Date.now();
2212+
this.emit('taskError', taskId, errorMsg);
2213+
} else {
2214+
this._status = 'idle';
2215+
}
22062216
this.emit('error', errorMsg);
22072217
});
22082218
}

src/state-store.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,10 @@ export class StateStore {
210210
return;
211211
}
212212

213+
// Clear dirty flag BEFORE async I/O so mutations during write re-set it.
214+
// The state snapshot is already captured in `json` above.
215+
this.dirty = false;
216+
213217
// Step 2: Create backup via file copy (async, no read+parse+write)
214218
try {
215219
await access(this.filePath);
@@ -223,15 +227,15 @@ export class StateStore {
223227
await writeFile(tempPath, json, 'utf-8');
224228
await rename(tempPath, this.filePath);
225229

226-
// Success! Clear dirty flag AFTER write completes
227-
this.dirty = false;
228230
this.consecutiveSaveFailures = 0;
229231
if (this.circuitBreakerOpen) {
230232
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
231233
this.circuitBreakerOpen = false;
232234
}
233235
} catch (err) {
234236
console.error('[StateStore] Failed to write state file:', err);
237+
// Re-mark dirty so the data is retried on the next save cycle
238+
this.dirty = true;
235239
this.consecutiveSaveFailures++;
236240

237241
// Try to clean up temp file on error

0 commit comments

Comments
 (0)