fix(terminal): linear-time link-provider regex; always allow blob workers in CSP

cmdPattern's empty-matchable unbounded arg group backtracked exponentially
on wrapped heredoc/table lines — hovering one froze the tab for minutes.
Non-empty tokens + bounded reps make it O(n); regression test extracts the
shipped patterns and pins timing on the real killer shapes.

worker-src 'self' blob: is now unconditional so terminal-ui's _safeYield
tick worker (throttling escape) isn't CSP-blocked on non-gesture installs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-10 18:06:45 +02:00
co-authored by Claude Fable 5
parent fad32eeaab
commit beeec63f72
17 changed files with 317 additions and 105 deletions
+10
View File
@@ -1,5 +1,15 @@
# aicodeman
## 0.9.11
### Patch Changes
- Fix a terminal freeze on hover (catastrophic regex backtracking) and a CSP violation that disabled the terminal's anti-throttling worker.
**Tab-freezing hover bug**: the terminal link provider's `cmdPattern` (which turns `tail -f /path`-style text into clickable links) used an empty-matchable, unbounded arg group — `(?:[^\s\/]*\s+)*` — that backtracks exponentially on real Claude output, e.g. wrapped `git commit -m "$(cat <<'EOF'` heredoc lines or aligned table rows. Hovering the mouse over such a line hung the page's main thread for minutes ("page unresponsive"). The pattern now uses non-empty tokens with bounded repetition (linear time); all intended command+path link forms still match. New `test/link-provider-regex.test.ts` extracts the shipped patterns from source and pins linear-time behavior on the killer line shapes.
**Blob worker CSP fix**: `worker-src 'self' blob:` is now always present in the CSP (previously only with `CODEMAN_GESTURE=1`). The terminal's `_safeYield` anti-throttling tick worker is created from a Blob URL and was silently blocked on every install, logging a CSP violation on each page load and disabling the worker leg of the render-yield fallback chain.
## 0.9.10
### Patch Changes
+5 -5
View File
@@ -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.
**Version**: 0.9.10 (must match `package.json`)
**Version**: 0.9.11 (must match `package.json`)
## Project Overview
@@ -127,7 +127,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Tasks** | `src/task.ts`, `src/task-queue.ts`, `src/task-tracker.ts` | |
| **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` | |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | |
| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts` | |
| **Frontend** | `src/web/public/app.js` (~3.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`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | |
| **Types** | `src/types/index.ts` (barrel) → 15 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
@@ -157,7 +157,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**External CLI modes (OpenCode, Codex)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). Both modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv`, never on the spawn command line: OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars` in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode); tmux exports `COLORTERM=truecolor` + unsets `NO_COLOR` (other modes unset `COLORTERM`); availability via `GET /api/codex/status` — session/quick-start routes fail with `OPERATION_FAILED` and an install hint (`npm install -g @openai/codex`) when the binary is missing. Frontend: run-mode dropdown → `runCodex()` in `session-ui.js` ("Run CX" label), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. Tests: `test/run-mode-ui.test.ts` (vm-sandbox harness, no real DOM).
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`.
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`.
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
@@ -211,7 +211,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
~135 handlers across 15 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (6), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
## Adding Features
@@ -249,7 +249,7 @@ Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pa
**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests.
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles).
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles). Browser-testing infra and practices: `docs/browser-testing-guide.md`.
## Debugging
+2 -2
View File
@@ -2,10 +2,10 @@
<img src="docs/images/codeman-title.svg" alt="Codeman" height="60">
</p>
<h2 align="center">The missing control plane for AI coding agents</h2>
<h2 align="center">Mission control for AI coding agents</h2>
<p align="center">
<em>Agent Visualization &bull; Zero-Lag Input &bull; Mobile-First UI &bull; Hardened Security</em>
<em>Claude Code &bull; OpenCode &bull; Codex &mdash; One Dashboard &bull; Zero-Lag Mobile Input &bull; Any Device</em>
</p>
<p align="center">
+2 -2
View File
@@ -2,10 +2,10 @@
<img src="docs/images/codeman-title.svg" alt="Codeman" height="60">
</p>
<h2 align="center">为 AI 编程智能体而生的「控制平面」</h2>
<h2 align="center">AI 编程智能体的任务控制中心</h2>
<p align="center">
<em>智能体可视化 &bull; 零延迟输入 &bull; 自主编排器 &bull; 重生控制器 &bull; 移动优先 UI &bull; 安全加固</em>
<em>Claude Code &bull; OpenCode &bull; Codex —— 统一仪表盘 &bull; 零延迟移动输入 &bull; 任意设备</em>
</p>
<p align="center">
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "0.9.10",
"version": "0.9.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "0.9.10",
"version": "0.9.11",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "aicodeman",
"version": "0.9.10",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"version": "0.9.11",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+1 -1
View File
@@ -39,7 +39,7 @@ Server echoes 'h' ←───────────────────
## Origin
This library was extracted from [Codeman](https://github.com/Ark0N/Codeman), the missing control plane for AI coding agents — multi-session management, real-time agent visualization, autonomous respawn loops, and a mobile-first web UI for Claude Code and OpenCode. The local echo system was built to make mobile and remote access feel instant, then battle-tested across thousands of hours of real usage. After 3 deep code audits, it was extracted into this standalone library with 78 tests covering every state transition.
This library was extracted from [Codeman](https://github.com/Ark0N/Codeman), mission control for AI coding agents — multi-session management, real-time agent visualization, autonomous respawn loops, and a mobile-first web UI for Claude Code, OpenCode, and Codex. The local echo system was built to make mobile and remote access feel instant, then battle-tested across thousands of hours of real usage. After 3 deep code audits, it was extracted into this standalone library with 78 tests covering every state transition.
## Install
+1
View File
@@ -8,3 +8,4 @@
export { RESEARCH_AGENT_PROMPT } from './research-agent.js';
export { PLANNER_PROMPT } from './planner.js';
export { PHASE_EXECUTION_PROMPT, TEAM_LEAD_PROMPT, REPLAN_PROMPT, SINGLE_TASK_PROMPT } from './orchestrator.js';
export { RALPH_STATUS_CONTRACT, buildRalphLoopPrompt, type RalphLoopPromptOptions } from './ralph.js';
+85
View File
@@ -0,0 +1,85 @@
/**
* @fileoverview Ralph Loop prompt construction
*
* Builds the full `@ralph_prompt.md` content written for a new Ralph loop
* session, including the RALPH_STATUS block contract. The contract travels
* with the loop prompt (not the generated CLAUDE.md) so every Ralph session
* emits parseable status blocks regardless of the project's CLAUDE.md.
*
* @module prompts/ralph
*/
/**
* Structured status-reporting contract appended to every Ralph loop prompt.
*
* `RalphStatusParser` (src/ralph-status-parser.ts) parses this block from
* session output — keep the field names and enum values in sync with its
* patterns.
*/
export const RALPH_STATUS_CONTRACT = `## Status Reporting
End EVERY response with exactly this block — Codeman parses it to track the loop:
\`\`\`
---RALPH_STATUS---
STATUS: IN_PROGRESS | COMPLETE | BLOCKED
TASKS_COMPLETED_THIS_LOOP: <number>
FILES_MODIFIED: <number>
TESTS_STATUS: PASSING | FAILING | NOT_RUN
WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING
EXIT_SIGNAL: false | true
RECOMMENDATION: <one line: what to do next>
---END_RALPH_STATUS---
\`\`\`
Rules:
- \`EXIT_SIGNAL: true\` only when ALL tasks are verifiably done — then also output the completion phrase
- \`STATUS: BLOCKED\` when you need human input; describe the blocker in RECOMMENDATION
- Never set \`EXIT_SIGNAL: true\` while tests are failing
`;
export interface RalphLoopPromptOptions {
/** The user's task description (becomes the prompt header) */
taskDescription: string;
/** Completion phrase the session must emit inside <promise></promise> */
completionPhrase: string;
/** Whether a @fix_plan.md task plan was generated for this loop */
hasPlan: boolean;
}
/**
* Builds the full Ralph loop prompt written to `@ralph_prompt.md`.
*/
export function buildRalphLoopPrompt({ taskDescription, completionPhrase, hasPlan }: RalphLoopPromptOptions): string {
let fullPrompt = taskDescription + '\n\n---\n\n';
if (hasPlan) {
fullPrompt += '## Task Plan\n\n';
fullPrompt += 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n';
fullPrompt += '- Reference the plan at the start of each iteration\n';
fullPrompt += '- Update task checkboxes as you complete items\n';
fullPrompt += '- Work through items in priority order (P0 > P1 > P2)\n\n';
}
fullPrompt += '## Iteration Protocol\n\n';
fullPrompt += 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n';
fullPrompt += '1. Check what work has already been done\n';
fullPrompt += '2. Make incremental progress toward completion\n';
fullPrompt += '3. Commit meaningful changes with descriptive messages\n\n';
fullPrompt += '## Verification\n\n';
fullPrompt += 'After each significant change:\n';
fullPrompt += '- Run tests to verify (npm test, pytest, etc.)\n';
fullPrompt += '- Check for type/lint errors if applicable\n';
fullPrompt += '- If tests fail, read the error, fix it, and retry\n\n';
fullPrompt += '## Completion Criteria\n\n';
fullPrompt += `Output \`<promise>${completionPhrase}</promise>\` when ALL of the following are true:\n`;
fullPrompt += '- All requirements from the task description are implemented\n';
fullPrompt += '- All tests pass\n';
fullPrompt += '- Changes are committed\n\n';
fullPrompt += '## If Stuck\n\n';
fullPrompt += 'If you encounter the same error for 3+ iterations:\n';
fullPrompt += "1. Document what you've tried\n";
fullPrompt += '2. Identify the specific blocker\n';
fullPrompt += '3. Try an alternative approach\n';
fullPrompt += '4. If truly blocked, output `<promise>BLOCKED</promise>` with an explanation\n\n';
fullPrompt += RALPH_STATUS_CONTRACT;
return fullPrompt;
}
-39
View File
@@ -56,42 +56,3 @@ This session is managed by Codeman and runs inside tmux (`CODEMAN_MUX=1` confirm
- NEVER kill your own session: no `tmux kill-session`, `pkill tmux`, or `pkill claude`.
- The session persists across disconnects — your work is safe.
- Hooks may auto-format or validate after writes; unexpected tool behavior usually means a hook ran. Keep working.
## Ralph Loop (Codeman Autonomous Mode)
Start: `/ralph-loop:ralph-loop` · Cancel: `/ralph-loop:cancel-ralph` · Help: `/ralph-loop:help`
You are in a Ralph loop when the prompt contains a completion phrase (e.g. `<promise>COMPLETE</promise>`). While looping:
- Work incrementally: one sub-task at a time — implement, verify, commit, move on.
- Fix failing tests and lint errors before starting the next task.
- Check `git log --oneline -10` / `git diff HEAD~1` before retrying an approach that already failed.
- Keep testing to ~20% of total effort; prioritize implementation. Don't refactor working code or add unrequested
features as busy work.
- If the prompt sets a minimum duration (e.g. "work for 4 hours"), record the start time (`date +%s`) and, when the
primary tasks finish early, keep generating useful related work (edge cases, coverage, docs, cleanup, hardening)
until the minimum time is reached.
Output the completion phrase ONLY when every requirement is verifiably done: all tests pass, lint is clean, the build
succeeds, and changes are committed. Never output it early — and never withhold it for busy work once everything is
genuinely done.
### RALPH_STATUS block (required)
End EVERY response during a Ralph loop with exactly this block — Codeman parses it to track the loop:
```
---RALPH_STATUS---
STATUS: IN_PROGRESS | COMPLETE | BLOCKED
TASKS_COMPLETED_THIS_LOOP: <number>
FILES_MODIFIED: <number>
TESTS_STATUS: PASSING | FAILING | NOT_RUN
WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING
EXIT_SIGNAL: false | true
RECOMMENDATION: <one line: what to do next>
---END_RALPH_STATUS---
```
- `EXIT_SIGNAL: true` only when ALL tasks are verifiably done — then also output the completion phrase.
- `STATUS: BLOCKED` when you need human input; describe the blocker in RECOMMENDATION.
- Never set `EXIT_SIGNAL: true` while tests are failing.
+6 -1
View File
@@ -205,7 +205,12 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean): v
const scriptSrc =
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net" + (gesture ? " 'wasm-unsafe-eval'" : '');
const connectSrc = "connect-src 'self' wss://api.deepgram.com";
const workerSrc = gesture ? "; worker-src 'self' blob:" : '';
// blob: workers are needed unconditionally: terminal-ui's _safeYield tick
// worker (throttling escape) is created from a Blob URL. Without this, every
// page load logs a CSP violation and the worker leg of _safeYield is dead.
// Risk is minimal — only same-origin scripts (already governed by script-src)
// can construct blob workers.
const workerSrc = "; worker-src 'self' blob:";
const csp =
`default-src 'self'; ${scriptSrc}; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; ` +
`img-src 'self' data: blob:; ${connectSrc}; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'${workerSrc}`;
+4 -1
View File
@@ -378,7 +378,10 @@ Object.assign(CodemanApp.prototype, {
prompt += `Output \`<promise>${config.completionPhrase}</promise>\` when done\n\n`;
prompt += '## If Stuck\n';
prompt += 'Output `<promise>BLOCKED</promise>` with explanation';
prompt += 'Output `<promise>BLOCKED</promise>` with explanation\n\n';
prompt += '## Status Reporting\n';
prompt += '• End every response with a `RALPH_STATUS` block (parsed by Codeman)';
// Show preview with highlighting (escape first, then apply formatting)
const escapedPrompt = escapeHtml(prompt);
+5 -1
View File
@@ -839,7 +839,11 @@ Object.assign(CodemanApp.prototype, {
// Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.)
// Handles: 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;
// ⚠ The arg group must stay linear-time: `(?:[^\s\/]*\s+)*` (empty-matchable
// token, unbounded) backtracks exponentially on lines with a trigger word
// followed by multi-space runs (e.g. wrapped heredoc/table output) — froze
// the whole tab on hover. Non-empty token + bounded reps is O(n).
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
// Pattern 2: Paths with common extensions
const extPattern =
+7 -31
View File
@@ -16,6 +16,7 @@ import { SseEvent } from '../sse-events.js';
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, findSessionOrFail, parseBody } from '../route-helpers.js';
import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { buildRalphLoopPrompt } from '../../prompts/index.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
@@ -382,37 +383,12 @@ export function registerRalphRoutes(
writeFileSync(fixPlanPath, planContent, 'utf-8');
}
// Build full prompt
const hasPlan = enabledItems.length > 0;
let fullPrompt = taskDescription + '\n\n---\n\n';
if (hasPlan) {
fullPrompt += '## Task Plan\n\n';
fullPrompt += 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n';
fullPrompt += '- Reference the plan at the start of each iteration\n';
fullPrompt += '- Update task checkboxes as you complete items\n';
fullPrompt += '- Work through items in priority order (P0 > P1 > P2)\n\n';
}
fullPrompt += '## Iteration Protocol\n\n';
fullPrompt += 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n';
fullPrompt += '1. Check what work has already been done\n';
fullPrompt += '2. Make incremental progress toward completion\n';
fullPrompt += '3. Commit meaningful changes with descriptive messages\n\n';
fullPrompt += '## Verification\n\n';
fullPrompt += 'After each significant change:\n';
fullPrompt += '- Run tests to verify (npm test, pytest, etc.)\n';
fullPrompt += '- Check for type/lint errors if applicable\n';
fullPrompt += '- If tests fail, read the error, fix it, and retry\n\n';
fullPrompt += '## Completion Criteria\n\n';
fullPrompt += `Output \`<promise>${completionPhrase}</promise>\` when ALL of the following are true:\n`;
fullPrompt += '- All requirements from the task description are implemented\n';
fullPrompt += '- All tests pass\n';
fullPrompt += '- Changes are committed\n\n';
fullPrompt += '## If Stuck\n\n';
fullPrompt += 'If you encounter the same error for 3+ iterations:\n';
fullPrompt += "1. Document what you've tried\n";
fullPrompt += '2. Identify the specific blocker\n';
fullPrompt += '3. Try an alternative approach\n';
fullPrompt += '4. If truly blocked, output `<promise>BLOCKED</promise>` with an explanation\n';
// Build full prompt (includes the RALPH_STATUS contract)
const fullPrompt = buildRalphLoopPrompt({
taskDescription,
completionPhrase,
hasPlan: enabledItems.length > 0,
});
// Write prompt to file
const promptPath = join(casePath, '@ralph_prompt.md');
+89
View File
@@ -0,0 +1,89 @@
/**
* @fileoverview Regression guard for the terminal link-provider regexes in
* `src/web/public/terminal-ui.js`.
*
* The link provider runs its patterns against every hovered terminal line
* (logical lines — xterm re-joins wrapped rows, so inputs reach multiple KB).
* A pattern with ambiguous backtracking freezes the entire tab on hover:
* 0.9.10's `cmdPattern` used `(?:[^\s\/]*\s+)*` (empty-matchable token,
* unbounded), which went exponential on real Claude output — wrapped
* `git commit -m "$(cat <<'EOF'` heredoc lines hung the main thread for
* minutes per hover.
*
* This test extracts the pattern literals FROM THE SHIPPED SOURCE (no copies
* that can drift) and asserts they stay linear-time on those killer shapes,
* and that `cmdPattern` still links the command+path forms it exists for.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
const SOURCE = readFileSync(join(__dirname, '..', 'src', 'web', 'public', 'terminal-ui.js'), 'utf-8');
/** Extract `const <name> = /.../g;` from the shipped source and build the RegExp. */
function shippedPattern(name: string): RegExp {
const m = SOURCE.match(new RegExp(`const ${name} =\\s*\\n?\\s*(/(?:[^/\\\\\\n]|\\\\.)+/[a-z]*)`));
if (!m) throw new Error(`pattern ${name} not found in terminal-ui.js`);
const lit = m[1];
const lastSlash = lit.lastIndexOf('/');
return new RegExp(lit.slice(1, lastSlash), lit.slice(lastSlash + 1));
}
const PATTERN_NAMES = ['urlPattern', 'cmdPattern', 'extPattern', 'bashPattern'];
/** Lines that made 0.9.10's cmdPattern backtrack exponentially (>2s each). */
const KILLER_LINES = [
// wrapped git-commit heredoc from real Claude tool output (the 0.9.10 freeze)
` /Users/arbbot/codeman-cases/topagent-control commit -m "$(cat <<'EOF'${' '.repeat(3000)}`,
// aligned table row: trigger word + multi-space-separated columns + mid-token slash
'watch ' + 'col '.repeat(40) + ' BTC/USDT',
// trigger word followed by many tokens and no token-initial path
'cat ' + 'word '.repeat(800) + 'no-path-here',
// long URL-ish and path-ish soup for the other patterns
'https://example.com/' + 'a/'.repeat(1500) + ' ' + '/home/x/'.repeat(400) + '.'.repeat(2000),
'Bash(' + 'x'.repeat(4000),
];
describe('terminal link-provider regexes (shipped source)', () => {
it('all patterns stay linear-time on killer lines', () => {
const patterns = PATTERN_NAMES.map((n) => [n, shippedPattern(n)] as const);
const start = Date.now();
for (const [, re] of patterns) {
for (const line of KILLER_LINES) {
re.lastIndex = 0;
while (re.exec(line) !== null) {
/* drain all matches like the provider does */
}
}
}
const elapsed = Date.now() - start;
// 20 pattern×line runs over multi-KB inputs: linear patterns finish in a few
// ms; the 0.9.10 cmdPattern alone needed minutes for ONE line.
expect(elapsed).toBeLessThan(500);
});
it('cmdPattern still links command + path forms', () => {
const cmd = shippedPattern('cmdPattern');
const cases: Array<[string, string]> = [
['tail -f /var/log/app.log', '/var/log/app.log'],
['cat -n /tmp/x.json', '/tmp/x.json'],
['grep -rn pattern /home/user/src', '/home/user/src'],
['watch ls /opt/data', '/opt/data'],
['head -c 100 /etc/hosts', '/etc/hosts'],
];
for (const [line, want] of cases) {
cmd.lastIndex = 0;
const m = cmd.exec(line);
expect(m, line).not.toBeNull();
expect(m![2]).toBe(want);
}
});
it('cmdPattern arg group cannot match empty tokens (the exponential trigger)', () => {
// structural guard: the dangerous construct is an empty-matchable token
// inside a repeated group — `[^\s\/]*\s+` repeated. Check the pattern
// literal itself (not the whole file — the warning comment quotes it).
const lit = shippedPattern('cmdPattern').source;
expect(lit).not.toContain('[^\\s\\/]*\\s+)*');
});
});
+92
View File
@@ -0,0 +1,92 @@
/**
* @fileoverview Tests for Ralph loop prompt construction
*
* Verifies buildRalphLoopPrompt() output, and that the RALPH_STATUS contract
* embedded in the prompt stays in sync with what RalphStatusParser parses.
*/
import { describe, it, expect } from 'vitest';
import { buildRalphLoopPrompt, RALPH_STATUS_CONTRACT } from '../src/prompts/ralph.js';
import { RalphStatusParser } from '../src/ralph-status-parser.js';
describe('buildRalphLoopPrompt', () => {
const baseOptions = {
taskDescription: 'Add CRUD endpoints for todos',
completionPhrase: 'COMPLETE',
hasPlan: false,
};
it('starts with the task description', () => {
const prompt = buildRalphLoopPrompt(baseOptions);
expect(prompt.startsWith('Add CRUD endpoints for todos\n\n---\n\n')).toBe(true);
});
it('embeds the completion phrase in the completion criteria', () => {
const prompt = buildRalphLoopPrompt({ ...baseOptions, completionPhrase: 'ALL_DONE' });
expect(prompt).toContain('<promise>ALL_DONE</promise>');
expect(prompt).toContain('## Completion Criteria');
});
it('includes the task plan section only when a plan exists', () => {
const withPlan = buildRalphLoopPrompt({ ...baseOptions, hasPlan: true });
const withoutPlan = buildRalphLoopPrompt(baseOptions);
expect(withPlan).toContain('## Task Plan');
expect(withPlan).toContain('@fix_plan.md');
expect(withoutPlan).not.toContain('## Task Plan');
});
it('always appends the RALPH_STATUS contract', () => {
const prompt = buildRalphLoopPrompt(baseOptions);
expect(prompt).toContain(RALPH_STATUS_CONTRACT);
expect(prompt).toContain('---RALPH_STATUS---');
expect(prompt).toContain('---END_RALPH_STATUS---');
});
it('documents every field RalphStatusParser expects', () => {
for (const field of [
'STATUS: IN_PROGRESS | COMPLETE | BLOCKED',
'TASKS_COMPLETED_THIS_LOOP: <number>',
'FILES_MODIFIED: <number>',
'TESTS_STATUS: PASSING | FAILING | NOT_RUN',
'WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING',
'EXIT_SIGNAL: false | true',
'RECOMMENDATION:',
]) {
expect(RALPH_STATUS_CONTRACT).toContain(field);
}
});
it('teaches a block format that RalphStatusParser actually parses', () => {
// A response following the contract to the letter
const conformingBlock = [
'---RALPH_STATUS---',
'STATUS: IN_PROGRESS',
'TASKS_COMPLETED_THIS_LOOP: 2',
'FILES_MODIFIED: 5',
'TESTS_STATUS: PASSING',
'WORK_TYPE: IMPLEMENTATION',
'EXIT_SIGNAL: false',
'RECOMMENDATION: Continue with the next endpoint',
'---END_RALPH_STATUS---',
];
const parser = new RalphStatusParser();
for (const line of conformingBlock) {
parser.processLine(line);
}
const block = parser.lastStatusBlock;
expect(block).not.toBeNull();
expect(block?.status).toBe('IN_PROGRESS');
expect(block?.tasksCompletedThisLoop).toBe(2);
expect(block?.filesModified).toBe(5);
expect(block?.testsStatus).toBe('PASSING');
expect(block?.workType).toBe('IMPLEMENTATION');
expect(block?.exitSignal).toBe(false);
expect(block?.recommendation).toBe('Continue with the next endpoint');
});
});
+4 -18
View File
@@ -68,24 +68,6 @@ describe('generateClaudeMd', () => {
expect(result).toContain('conventional commits');
});
it('should include Ralph Loop section with slash commands', () => {
const result = generateClaudeMd('my-project');
expect(result).toContain('## Ralph Loop');
expect(result).toContain('/ralph-loop:ralph-loop');
expect(result).toContain('/ralph-loop:cancel-ralph');
});
it('should include the RALPH_STATUS contract parsed by ralph-status-parser', () => {
const result = generateClaudeMd('my-project');
expect(result).toContain('---RALPH_STATUS---');
expect(result).toContain('---END_RALPH_STATUS---');
expect(result).toContain('STATUS: IN_PROGRESS | COMPLETE | BLOCKED');
expect(result).toContain('EXIT_SIGNAL: false | true');
expect(result).toContain('completion phrase');
});
it('should stay under the 200-line CLAUDE.md guidance', () => {
const result = generateClaudeMd('my-project');
@@ -99,6 +81,10 @@ describe('generateClaudeMd', () => {
expect(result).not.toContain('TodoWrite');
expect(result).not.toContain('## Planning Mode');
expect(result).not.toContain('[TECHNOLOGIES_USED]');
// Ralph loop instructions live in the loop prompt (wizard) and plugin,
// not in every project's CLAUDE.md
expect(result).not.toContain('RALPH_STATUS');
expect(result).not.toContain('/ralph-loop:');
});
});