Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db8ee8458c | ||
|
|
88098adc1c | ||
|
|
c4cdab03e2 | ||
|
|
51a01cf5d1 | ||
|
|
75765589ca | ||
|
|
c117b59882 | ||
|
|
e011727c00 | ||
|
|
f2e1e986bd | ||
|
|
545913790b | ||
|
|
cbb1ce8657 | ||
|
|
9d9f4d323c | ||
|
|
1ab1dd100a | ||
|
|
ec7244fa5b | ||
|
|
6053593813 | ||
|
|
93ebe3f07e | ||
|
|
1b0a540919 | ||
|
|
45aaf7f09a | ||
|
|
0a29d8d6ca | ||
|
|
2097831acf | ||
|
|
62a81a02a3 | ||
|
|
864522b68d | ||
|
|
c10edce904 | ||
|
|
480584de63 |
@@ -1,61 +0,0 @@
|
||||
---
|
||||
name: remotion-best-practices
|
||||
description: Best practices for Remotion - Video creation in React
|
||||
metadata:
|
||||
tags: remotion, video, react, animation, composition
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
Use this skills whenever you are dealing with Remotion code to obtain the domain-specific knowledge.
|
||||
|
||||
## Captions
|
||||
|
||||
When dealing with captions or subtitles, load the [./rules/subtitles.md](./rules/subtitles.md) file for more information.
|
||||
|
||||
## Using FFmpeg
|
||||
|
||||
For some video operations, such as trimming videos or detecting silence, FFmpeg should be used. Load the [./rules/ffmpeg.md](./rules/ffmpeg.md) file for more information.
|
||||
|
||||
## Audio visualization
|
||||
|
||||
When needing to visualize audio (spectrum bars, waveforms, bass-reactive effects), load the [./rules/audio-visualization.md](./rules/audio-visualization.md) file for more information.
|
||||
|
||||
## Sound effects
|
||||
|
||||
When needing to use sound effects, load the [./rules/sound-effects.md](./rules/sound-effects.md) file for more information.
|
||||
|
||||
## How to use
|
||||
|
||||
Read individual rule files for detailed explanations and code examples:
|
||||
|
||||
- [rules/3d.md](rules/3d.md) - 3D content in Remotion using Three.js and React Three Fiber
|
||||
- [rules/animations.md](rules/animations.md) - Fundamental animation skills for Remotion
|
||||
- [rules/assets.md](rules/assets.md) - Importing images, videos, audio, and fonts into Remotion
|
||||
- [rules/audio.md](rules/audio.md) - Using audio and sound in Remotion - importing, trimming, volume, speed, pitch
|
||||
- [rules/calculate-metadata.md](rules/calculate-metadata.md) - Dynamically set composition duration, dimensions, and props
|
||||
- [rules/can-decode.md](rules/can-decode.md) - Check if a video can be decoded by the browser using Mediabunny
|
||||
- [rules/charts.md](rules/charts.md) - Chart and data visualization patterns for Remotion (bar, pie, line, stock charts)
|
||||
- [rules/compositions.md](rules/compositions.md) - Defining compositions, stills, folders, default props and dynamic metadata
|
||||
- [rules/extract-frames.md](rules/extract-frames.md) - Extract frames from videos at specific timestamps using Mediabunny
|
||||
- [rules/fonts.md](rules/fonts.md) - Loading Google Fonts and local fonts in Remotion
|
||||
- [rules/get-audio-duration.md](rules/get-audio-duration.md) - Getting the duration of an audio file in seconds with Mediabunny
|
||||
- [rules/get-video-dimensions.md](rules/get-video-dimensions.md) - Getting the width and height of a video file with Mediabunny
|
||||
- [rules/get-video-duration.md](rules/get-video-duration.md) - Getting the duration of a video file in seconds with Mediabunny
|
||||
- [rules/gifs.md](rules/gifs.md) - Displaying GIFs synchronized with Remotion's timeline
|
||||
- [rules/images.md](rules/images.md) - Embedding images in Remotion using the Img component
|
||||
- [rules/light-leaks.md](rules/light-leaks.md) - Light leak overlay effects using @remotion/light-leaks
|
||||
- [rules/lottie.md](rules/lottie.md) - Embedding Lottie animations in Remotion
|
||||
- [rules/measuring-dom-nodes.md](rules/measuring-dom-nodes.md) - Measuring DOM element dimensions in Remotion
|
||||
- [rules/measuring-text.md](rules/measuring-text.md) - Measuring text dimensions, fitting text to containers, and checking overflow
|
||||
- [rules/sequencing.md](rules/sequencing.md) - Sequencing patterns for Remotion - delay, trim, limit duration of items
|
||||
- [rules/tailwind.md](rules/tailwind.md) - Using TailwindCSS in Remotion
|
||||
- [rules/text-animations.md](rules/text-animations.md) - Typography and text animation patterns for Remotion
|
||||
- [rules/timing.md](rules/timing.md) - Interpolation curves in Remotion - linear, easing, spring animations
|
||||
- [rules/transitions.md](rules/transitions.md) - Scene transition patterns for Remotion
|
||||
- [rules/transparent-videos.md](rules/transparent-videos.md) - Rendering out a video with transparency
|
||||
- [rules/trimming.md](rules/trimming.md) - Trimming patterns for Remotion - cut the beginning or end of animations
|
||||
- [rules/videos.md](rules/videos.md) - Embedding videos in Remotion - trimming, volume, speed, looping, pitch
|
||||
- [rules/parameters.md](rules/parameters.md) - Make a video parametrizable by adding a Zod schema
|
||||
- [rules/maps.md](rules/maps.md) - Add a map using Mapbox and animate it
|
||||
- [rules/voiceover.md](rules/voiceover.md) - Adding AI-generated voiceover to Remotion compositions using ElevenLabs TTS
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
name: 3d
|
||||
description: 3D content in Remotion using Three.js and React Three Fiber.
|
||||
metadata:
|
||||
tags: 3d, three, threejs
|
||||
---
|
||||
|
||||
# Using Three.js and React Three Fiber in Remotion
|
||||
|
||||
Follow React Three Fiber and Three.js best practices.
|
||||
Only the following Remotion-specific rules need to be followed:
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the `@remotion/three` package needs to be installed.
|
||||
If it is not, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/three # If project uses npm
|
||||
bunx remotion add @remotion/three # If project uses bun
|
||||
yarn remotion add @remotion/three # If project uses yarn
|
||||
pnpm exec remotion add @remotion/three # If project uses pnpm
|
||||
```
|
||||
|
||||
## Using ThreeCanvas
|
||||
|
||||
You MUST wrap 3D content in `<ThreeCanvas>` and include proper lighting.
|
||||
`<ThreeCanvas>` MUST have a `width` and `height` prop.
|
||||
|
||||
```tsx
|
||||
import { ThreeCanvas } from "@remotion/three";
|
||||
import { useVideoConfig } from "remotion";
|
||||
|
||||
const { width, height } = useVideoConfig();
|
||||
|
||||
<ThreeCanvas width={width} height={height}>
|
||||
<ambientLight intensity={0.4} />
|
||||
<directionalLight position={[5, 5, 5]} intensity={0.8} />
|
||||
<mesh>
|
||||
<sphereGeometry args={[1, 32, 32]} />
|
||||
<meshStandardMaterial color="red" />
|
||||
</mesh>
|
||||
</ThreeCanvas>;
|
||||
```
|
||||
|
||||
## No animations not driven by `useCurrentFrame()`
|
||||
|
||||
Shaders, models etc MUST NOT animate by themselves.
|
||||
No animations are allowed unless they are driven by `useCurrentFrame()`.
|
||||
Otherwise, it will cause flickering during rendering.
|
||||
|
||||
Using `useFrame()` from `@react-three/fiber` is forbidden.
|
||||
|
||||
## Animate using `useCurrentFrame()`
|
||||
|
||||
Use `useCurrentFrame()` to perform animations.
|
||||
|
||||
```tsx
|
||||
const frame = useCurrentFrame();
|
||||
const rotationY = frame * 0.02;
|
||||
|
||||
<mesh rotation={[0, rotationY, 0]}>
|
||||
<boxGeometry args={[2, 2, 2]} />
|
||||
<meshStandardMaterial color="#4a9eff" />
|
||||
</mesh>;
|
||||
```
|
||||
|
||||
## Using `<Sequence>` inside `<ThreeCanvas>`
|
||||
|
||||
The `layout` prop of any `<Sequence>` inside a `<ThreeCanvas>` must be set to `none`.
|
||||
|
||||
```tsx
|
||||
import { Sequence } from "remotion";
|
||||
import { ThreeCanvas } from "@remotion/three";
|
||||
|
||||
const { width, height } = useVideoConfig();
|
||||
|
||||
<ThreeCanvas width={width} height={height}>
|
||||
<Sequence layout="none">
|
||||
<mesh>
|
||||
<boxGeometry args={[2, 2, 2]} />
|
||||
<meshStandardMaterial color="#4a9eff" />
|
||||
</mesh>
|
||||
</Sequence>
|
||||
</ThreeCanvas>;
|
||||
```
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: animations
|
||||
description: Fundamental animation skills for Remotion
|
||||
metadata:
|
||||
tags: animations, transitions, frames, useCurrentFrame
|
||||
---
|
||||
|
||||
All animations MUST be driven by the `useCurrentFrame()` hook.
|
||||
Write animations in seconds and multiply them by the `fps` value from `useVideoConfig()`.
|
||||
|
||||
```tsx
|
||||
import { useCurrentFrame } from "remotion";
|
||||
|
||||
export const FadeIn = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const opacity = interpolate(frame, [0, 2 * fps], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return <div style={{ opacity }}>Hello World!</div>;
|
||||
};
|
||||
```
|
||||
|
||||
CSS transitions or animations are FORBIDDEN - they will not render correctly.
|
||||
Tailwind animation class names are FORBIDDEN - they will not render correctly.
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
name: assets
|
||||
description: Importing images, videos, audio, and fonts into Remotion
|
||||
metadata:
|
||||
tags: assets, staticFile, images, fonts, public
|
||||
---
|
||||
|
||||
# Importing assets in Remotion
|
||||
|
||||
## The public folder
|
||||
|
||||
Place assets in the `public/` folder at your project root.
|
||||
|
||||
## Using staticFile()
|
||||
|
||||
You MUST use `staticFile()` to reference files from the `public/` folder:
|
||||
|
||||
```tsx
|
||||
import { Img, staticFile } from "remotion";
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <Img src={staticFile("logo.png")} />;
|
||||
};
|
||||
```
|
||||
|
||||
The function returns an encoded URL that works correctly when deploying to subdirectories.
|
||||
|
||||
## Using with components
|
||||
|
||||
**Images:**
|
||||
|
||||
```tsx
|
||||
import { Img, staticFile } from "remotion";
|
||||
|
||||
<Img src={staticFile("photo.png")} />;
|
||||
```
|
||||
|
||||
**Videos:**
|
||||
|
||||
```tsx
|
||||
import { Video } from "@remotion/media";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
<Video src={staticFile("clip.mp4")} />;
|
||||
```
|
||||
|
||||
**Audio:**
|
||||
|
||||
```tsx
|
||||
import { Audio } from "@remotion/media";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
<Audio src={staticFile("music.mp3")} />;
|
||||
```
|
||||
|
||||
**Fonts:**
|
||||
|
||||
```tsx
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
const fontFamily = new FontFace("MyFont", `url(${staticFile("font.woff2")})`);
|
||||
await fontFamily.load();
|
||||
document.fonts.add(fontFamily);
|
||||
```
|
||||
|
||||
## Remote URLs
|
||||
|
||||
Remote URLs can be used directly without `staticFile()`:
|
||||
|
||||
```tsx
|
||||
<Img src="https://example.com/image.png" />
|
||||
<Video src="https://remotion.media/video.mp4" />
|
||||
```
|
||||
|
||||
## Important notes
|
||||
|
||||
- Remotion components (`<Img>`, `<Video>`, `<Audio>`) ensure assets are fully loaded before rendering
|
||||
- Special characters in filenames (`#`, `?`, `&`) are automatically encoded
|
||||
@@ -1,173 +0,0 @@
|
||||
import {loadFont} from '@remotion/google-fonts/Inter';
|
||||
import {AbsoluteFill, spring, useCurrentFrame, useVideoConfig} from 'remotion';
|
||||
|
||||
const {fontFamily} = loadFont();
|
||||
|
||||
const COLOR_BAR = '#D4AF37';
|
||||
const COLOR_TEXT = '#ffffff';
|
||||
const COLOR_MUTED = '#888888';
|
||||
const COLOR_BG = '#0a0a0a';
|
||||
const COLOR_AXIS = '#333333';
|
||||
|
||||
// Ideal composition size: 1280x720
|
||||
|
||||
const Title: React.FC<{children: React.ReactNode}> = ({children}) => (
|
||||
<div style={{textAlign: 'center', marginBottom: 40}}>
|
||||
<div style={{color: COLOR_TEXT, fontSize: 48, fontWeight: 600}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const YAxis: React.FC<{steps: number[]; height: number}> = ({
|
||||
steps,
|
||||
height,
|
||||
}) => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
height,
|
||||
paddingRight: 16,
|
||||
}}
|
||||
>
|
||||
{steps
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
style={{
|
||||
color: COLOR_MUTED,
|
||||
fontSize: 20,
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{step.toLocaleString()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Bar: React.FC<{
|
||||
height: number;
|
||||
progress: number;
|
||||
}> = ({height, progress}) => (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height,
|
||||
backgroundColor: COLOR_BAR,
|
||||
borderRadius: '8px 8px 0 0',
|
||||
opacity: progress,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const XAxis: React.FC<{
|
||||
children: React.ReactNode;
|
||||
labels: string[];
|
||||
height: number;
|
||||
}> = ({children, labels, height}) => (
|
||||
<div style={{flex: 1, display: 'flex', flexDirection: 'column'}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: 16,
|
||||
height,
|
||||
borderLeft: `2px solid ${COLOR_AXIS}`,
|
||||
borderBottom: `2px solid ${COLOR_AXIS}`,
|
||||
paddingLeft: 16,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
paddingLeft: 16,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
{labels.map((label) => (
|
||||
<div
|
||||
key={label}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
color: COLOR_MUTED,
|
||||
fontSize: 20,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MyAnimation = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const {fps, height} = useVideoConfig();
|
||||
|
||||
const data = [
|
||||
{month: 'Jan', price: 2039},
|
||||
{month: 'Mar', price: 2160},
|
||||
{month: 'May', price: 2327},
|
||||
{month: 'Jul', price: 2426},
|
||||
{month: 'Sep', price: 2634},
|
||||
{month: 'Nov', price: 2672},
|
||||
];
|
||||
|
||||
const minPrice = 2000;
|
||||
const maxPrice = 2800;
|
||||
const priceRange = maxPrice - minPrice;
|
||||
const chartHeight = height - 280;
|
||||
const yAxisSteps = [2000, 2400, 2800];
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: COLOR_BG,
|
||||
padding: 60,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontFamily,
|
||||
}}
|
||||
>
|
||||
<Title>Gold Price 2024</Title>
|
||||
|
||||
<div style={{display: 'flex', flex: 1}}>
|
||||
<YAxis steps={yAxisSteps} height={chartHeight} />
|
||||
<XAxis height={chartHeight} labels={data.map((d) => d.month)}>
|
||||
{data.map((item, i) => {
|
||||
const progress = spring({
|
||||
frame: frame - i * 5 - 10,
|
||||
fps,
|
||||
config: {damping: 18, stiffness: 80},
|
||||
});
|
||||
|
||||
const barHeight =
|
||||
((item.price - minPrice) / priceRange) * chartHeight * progress;
|
||||
|
||||
return (
|
||||
<Bar key={item.month} height={barHeight} progress={progress} />
|
||||
);
|
||||
})}
|
||||
</XAxis>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
|
||||
const COLOR_BG = '#ffffff';
|
||||
const COLOR_TEXT = '#000000';
|
||||
const FULL_TEXT = 'From prompt to motion graphics. This is Remotion.';
|
||||
const PAUSE_AFTER = 'From prompt to motion graphics.';
|
||||
const FONT_SIZE = 72;
|
||||
const FONT_WEIGHT = 700;
|
||||
const CHAR_FRAMES = 2;
|
||||
const CURSOR_BLINK_FRAMES = 16;
|
||||
const PAUSE_SECONDS = 1;
|
||||
|
||||
// Ideal composition size: 1280x720
|
||||
|
||||
const getTypedText = ({
|
||||
frame,
|
||||
fullText,
|
||||
pauseAfter,
|
||||
charFrames,
|
||||
pauseFrames,
|
||||
}: {
|
||||
frame: number;
|
||||
fullText: string;
|
||||
pauseAfter: string;
|
||||
charFrames: number;
|
||||
pauseFrames: number;
|
||||
}): string => {
|
||||
const pauseIndex = fullText.indexOf(pauseAfter);
|
||||
const preLen =
|
||||
pauseIndex >= 0 ? pauseIndex + pauseAfter.length : fullText.length;
|
||||
|
||||
let typedChars = 0;
|
||||
if (frame < preLen * charFrames) {
|
||||
typedChars = Math.floor(frame / charFrames);
|
||||
} else if (frame < preLen * charFrames + pauseFrames) {
|
||||
typedChars = preLen;
|
||||
} else {
|
||||
const postPhase = frame - preLen * charFrames - pauseFrames;
|
||||
typedChars = Math.min(
|
||||
fullText.length,
|
||||
preLen + Math.floor(postPhase / charFrames),
|
||||
);
|
||||
}
|
||||
return fullText.slice(0, typedChars);
|
||||
};
|
||||
|
||||
const Cursor: React.FC<{
|
||||
frame: number;
|
||||
blinkFrames: number;
|
||||
symbol?: string;
|
||||
}> = ({frame, blinkFrames, symbol = '\u258C'}) => {
|
||||
const opacity = interpolate(
|
||||
frame % blinkFrames,
|
||||
[0, blinkFrames / 2, blinkFrames],
|
||||
[1, 0, 1],
|
||||
{extrapolateLeft: 'clamp', extrapolateRight: 'clamp'},
|
||||
);
|
||||
|
||||
return <span style={{opacity}}>{symbol}</span>;
|
||||
};
|
||||
|
||||
export const MyAnimation = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const {fps} = useVideoConfig();
|
||||
|
||||
const pauseFrames = Math.round(fps * PAUSE_SECONDS);
|
||||
|
||||
const typedText = getTypedText({
|
||||
frame,
|
||||
fullText: FULL_TEXT,
|
||||
pauseAfter: PAUSE_AFTER,
|
||||
charFrames: CHAR_FRAMES,
|
||||
pauseFrames,
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: COLOR_BG,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: COLOR_TEXT,
|
||||
fontSize: FONT_SIZE,
|
||||
fontWeight: FONT_WEIGHT,
|
||||
fontFamily: 'sans-serif',
|
||||
}}
|
||||
>
|
||||
<span>{typedText}</span>
|
||||
<Cursor frame={frame} blinkFrames={CURSOR_BLINK_FRAMES} />
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
import {loadFont} from '@remotion/google-fonts/Inter';
|
||||
import React from 'react';
|
||||
import {AbsoluteFill, spring, useCurrentFrame, useVideoConfig} from 'remotion';
|
||||
|
||||
/*
|
||||
* Highlight a word in a sentence with a spring-animated wipe effect.
|
||||
*/
|
||||
|
||||
// Ideal composition size: 1280x720
|
||||
|
||||
const COLOR_BG = '#ffffff';
|
||||
const COLOR_TEXT = '#000000';
|
||||
const COLOR_HIGHLIGHT = '#A7C7E7';
|
||||
const FULL_TEXT = 'This is Remotion.';
|
||||
const HIGHLIGHT_WORD = 'Remotion';
|
||||
const FONT_SIZE = 72;
|
||||
const FONT_WEIGHT = 700;
|
||||
const HIGHLIGHT_START_FRAME = 30;
|
||||
const HIGHLIGHT_WIPE_DURATION = 18;
|
||||
|
||||
const {fontFamily} = loadFont();
|
||||
|
||||
const Highlight: React.FC<{
|
||||
word: string;
|
||||
color: string;
|
||||
delay: number;
|
||||
durationInFrames: number;
|
||||
}> = ({word, color, delay, durationInFrames}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const {fps} = useVideoConfig();
|
||||
|
||||
const highlightProgress = spring({
|
||||
fps,
|
||||
frame,
|
||||
config: {damping: 200},
|
||||
delay,
|
||||
durationInFrames,
|
||||
});
|
||||
const scaleX = Math.max(0, Math.min(1, highlightProgress));
|
||||
|
||||
return (
|
||||
<span style={{position: 'relative', display: 'inline-block'}}>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: '50%',
|
||||
height: '1.05em',
|
||||
transform: `translateY(-50%) scaleX(${scaleX})`,
|
||||
transformOrigin: 'left center',
|
||||
backgroundColor: color,
|
||||
borderRadius: '0.18em',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
<span style={{position: 'relative', zIndex: 1}}>{word}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const MyAnimation = () => {
|
||||
const highlightIndex = FULL_TEXT.indexOf(HIGHLIGHT_WORD);
|
||||
const hasHighlight = highlightIndex >= 0;
|
||||
const preText = hasHighlight ? FULL_TEXT.slice(0, highlightIndex) : FULL_TEXT;
|
||||
const postText = hasHighlight
|
||||
? FULL_TEXT.slice(highlightIndex + HIGHLIGHT_WORD.length)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: COLOR_BG,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: COLOR_TEXT,
|
||||
fontSize: FONT_SIZE,
|
||||
fontWeight: FONT_WEIGHT,
|
||||
}}
|
||||
>
|
||||
{hasHighlight ? (
|
||||
<>
|
||||
<span>{preText}</span>
|
||||
<Highlight
|
||||
word={HIGHLIGHT_WORD}
|
||||
color={COLOR_HIGHLIGHT}
|
||||
delay={HIGHLIGHT_START_FRAME}
|
||||
durationInFrames={HIGHLIGHT_WIPE_DURATION}
|
||||
/>
|
||||
<span>{postText}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{FULL_TEXT}</span>
|
||||
)}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,198 +0,0 @@
|
||||
---
|
||||
name: audio-visualization
|
||||
description: Audio visualization patterns - spectrum bars, waveforms, bass-reactive effects
|
||||
metadata:
|
||||
tags: audio, visualization, spectrum, waveform, bass, music, audiogram, frequency
|
||||
---
|
||||
|
||||
# Audio Visualization in Remotion
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/media-utils
|
||||
```
|
||||
|
||||
## Loading Audio Data
|
||||
|
||||
Use `useWindowedAudioData()` (https://www.remotion.dev/docs/use-windowed-audio-data) to load audio data:
|
||||
|
||||
```tsx
|
||||
import { useWindowedAudioData } from "@remotion/media-utils";
|
||||
import { staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
|
||||
src: staticFile("podcast.wav"),
|
||||
frame,
|
||||
fps,
|
||||
windowInSeconds: 30,
|
||||
});
|
||||
```
|
||||
|
||||
## Spectrum Bar Visualization
|
||||
|
||||
Use `visualizeAudio()` (https://www.remotion.dev/docs/visualize-audio) to get frequency data for bar charts:
|
||||
|
||||
```tsx
|
||||
import { useWindowedAudioData, visualizeAudio } from "@remotion/media-utils";
|
||||
import { staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
|
||||
src: staticFile("music.mp3"),
|
||||
frame,
|
||||
fps,
|
||||
windowInSeconds: 30,
|
||||
});
|
||||
|
||||
if (!audioData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frequencies = visualizeAudio({
|
||||
fps,
|
||||
frame,
|
||||
audioData,
|
||||
numberOfSamples: 256,
|
||||
optimizeFor: "speed",
|
||||
dataOffsetInSeconds,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "flex-end", height: 200 }}>
|
||||
{frequencies.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: `${v * 100}%`,
|
||||
backgroundColor: "#0b84f3",
|
||||
margin: "0 1px",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
- `numberOfSamples` must be power of 2 (32, 64, 128, 256, 512, 1024)
|
||||
- Values range 0-1; left of array = bass, right = highs
|
||||
- Use `optimizeFor: "speed"` for Lambda or high sample counts
|
||||
|
||||
**Important:** When passing `audioData` to child components, also pass the `frame` from the parent. Do not call `useCurrentFrame()` in each child - this causes discontinuous visualization when children are inside `<Sequence>` with offsets.
|
||||
|
||||
## Waveform Visualization
|
||||
|
||||
Use `visualizeAudioWaveform()` (https://www.remotion.dev/docs/media-utils/visualize-audio-waveform) with `createSmoothSvgPath()` (https://www.remotion.dev/docs/media-utils/create-smooth-svg-path) for oscilloscope-style displays:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
createSmoothSvgPath,
|
||||
useWindowedAudioData,
|
||||
visualizeAudioWaveform,
|
||||
} from "@remotion/media-utils";
|
||||
import { staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
const { width, fps } = useVideoConfig();
|
||||
const HEIGHT = 200;
|
||||
|
||||
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
|
||||
src: staticFile("voice.wav"),
|
||||
frame,
|
||||
fps,
|
||||
windowInSeconds: 30,
|
||||
});
|
||||
|
||||
if (!audioData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const waveform = visualizeAudioWaveform({
|
||||
fps,
|
||||
frame,
|
||||
audioData,
|
||||
numberOfSamples: 256,
|
||||
windowInSeconds: 0.5,
|
||||
dataOffsetInSeconds,
|
||||
});
|
||||
|
||||
const path = createSmoothSvgPath({
|
||||
points: waveform.map((y, i) => ({
|
||||
x: (i / (waveform.length - 1)) * width,
|
||||
y: HEIGHT / 2 + (y * HEIGHT) / 2,
|
||||
})),
|
||||
});
|
||||
|
||||
return (
|
||||
<svg width={width} height={HEIGHT}>
|
||||
<path d={path} fill="none" stroke="#0b84f3" strokeWidth={2} />
|
||||
</svg>
|
||||
);
|
||||
```
|
||||
|
||||
## Bass-Reactive Effects
|
||||
|
||||
Extract low frequencies for beat-reactive animations:
|
||||
|
||||
```tsx
|
||||
const frequencies = visualizeAudio({
|
||||
fps,
|
||||
frame,
|
||||
audioData,
|
||||
numberOfSamples: 128,
|
||||
optimizeFor: "speed",
|
||||
dataOffsetInSeconds,
|
||||
});
|
||||
|
||||
const lowFrequencies = frequencies.slice(0, 32);
|
||||
const bassIntensity =
|
||||
lowFrequencies.reduce((sum, v) => sum + v, 0) / lowFrequencies.length;
|
||||
|
||||
const scale = 1 + bassIntensity * 0.5;
|
||||
const opacity = Math.min(0.6, bassIntensity * 0.8);
|
||||
```
|
||||
|
||||
## Volume-Based Waveform
|
||||
|
||||
Use `getWaveformPortion()` (https://www.remotion.dev/docs/get-waveform-portion) when you need simplified volume data instead of frequency spectrum:
|
||||
|
||||
```tsx
|
||||
import { getWaveformPortion } from "@remotion/media-utils";
|
||||
import { useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const currentTimeInSeconds = frame / fps;
|
||||
|
||||
const waveform = getWaveformPortion({
|
||||
audioData,
|
||||
startTimeInSeconds: currentTimeInSeconds,
|
||||
durationInSeconds: 5,
|
||||
numberOfSamples: 50,
|
||||
});
|
||||
|
||||
// Returns array of { index, amplitude } objects (amplitude: 0-1)
|
||||
waveform.map((bar) => (
|
||||
<div key={bar.index} style={{ height: bar.amplitude * 100 }} />
|
||||
));
|
||||
```
|
||||
|
||||
## Postprocessing
|
||||
|
||||
Low frequencies naturally dominate. Apply logarithmic scaling for visual balance:
|
||||
|
||||
```tsx
|
||||
const minDb = -100;
|
||||
const maxDb = -30;
|
||||
|
||||
const scaled = frequencies.map((value) => {
|
||||
const db = 20 * Math.log10(value);
|
||||
return (db - minDb) / (maxDb - minDb);
|
||||
});
|
||||
```
|
||||
@@ -1,169 +0,0 @@
|
||||
---
|
||||
name: audio
|
||||
description: Using audio and sound in Remotion - importing, trimming, volume, speed, pitch
|
||||
metadata:
|
||||
tags: audio, media, trim, volume, speed, loop, pitch, mute, sound, sfx
|
||||
---
|
||||
|
||||
# Using audio in Remotion
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the @remotion/media package needs to be installed.
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/media
|
||||
```
|
||||
|
||||
## Importing Audio
|
||||
|
||||
Use `<Audio>` from `@remotion/media` to add audio to your composition.
|
||||
|
||||
```tsx
|
||||
import { Audio } from "@remotion/media";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <Audio src={staticFile("audio.mp3")} />;
|
||||
};
|
||||
```
|
||||
|
||||
Remote URLs are also supported:
|
||||
|
||||
```tsx
|
||||
<Audio src="https://remotion.media/audio.mp3" />
|
||||
```
|
||||
|
||||
By default, audio plays from the start, at full volume and full length.
|
||||
Multiple audio tracks can be layered by adding multiple `<Audio>` components.
|
||||
|
||||
## Trimming
|
||||
|
||||
Use `trimBefore` and `trimAfter` to remove portions of the audio. Values are in frames.
|
||||
|
||||
```tsx
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
trimBefore={2 * fps} // Skip the first 2 seconds
|
||||
trimAfter={10 * fps} // End at the 10 second mark
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
The audio still starts playing at the beginning of the composition - only the specified portion is played.
|
||||
|
||||
## Delaying
|
||||
|
||||
Wrap the audio in a `<Sequence>` to delay when it starts:
|
||||
|
||||
```tsx
|
||||
import { Sequence, staticFile } from "remotion";
|
||||
import { Audio } from "@remotion/media";
|
||||
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Sequence from={1 * fps}>
|
||||
<Audio src={staticFile("audio.mp3")} />
|
||||
</Sequence>
|
||||
);
|
||||
```
|
||||
|
||||
The audio will start playing after 1 second.
|
||||
|
||||
## Volume
|
||||
|
||||
Set a static volume (0 to 1):
|
||||
|
||||
```tsx
|
||||
<Audio src={staticFile("audio.mp3")} volume={0.5} />
|
||||
```
|
||||
|
||||
Or use a callback for dynamic volume based on the current frame:
|
||||
|
||||
```tsx
|
||||
import { interpolate } from "remotion";
|
||||
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
volume={(f) =>
|
||||
interpolate(f, [0, 1 * fps], [0, 1], { extrapolateRight: "clamp" })
|
||||
}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
The value of `f` starts at 0 when the audio begins to play, not the composition frame.
|
||||
|
||||
## Muting
|
||||
|
||||
Use `muted` to silence the audio. It can be set dynamically:
|
||||
|
||||
```tsx
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
muted={frame >= 2 * fps && frame <= 4 * fps} // Mute between 2s and 4s
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
## Speed
|
||||
|
||||
Use `playbackRate` to change the playback speed:
|
||||
|
||||
```tsx
|
||||
<Audio src={staticFile("audio.mp3")} playbackRate={2} /> {/* 2x speed */}
|
||||
<Audio src={staticFile("audio.mp3")} playbackRate={0.5} /> {/* Half speed */}
|
||||
```
|
||||
|
||||
Reverse playback is not supported.
|
||||
|
||||
## Looping
|
||||
|
||||
Use `loop` to loop the audio indefinitely:
|
||||
|
||||
```tsx
|
||||
<Audio src={staticFile("audio.mp3")} loop />
|
||||
```
|
||||
|
||||
Use `loopVolumeCurveBehavior` to control how the frame count behaves when looping:
|
||||
|
||||
- `"repeat"`: Frame count resets to 0 each loop (default)
|
||||
- `"extend"`: Frame count continues incrementing
|
||||
|
||||
```tsx
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
loop
|
||||
loopVolumeCurveBehavior="extend"
|
||||
volume={(f) => interpolate(f, [0, 300], [1, 0])} // Fade out over multiple loops
|
||||
/>
|
||||
```
|
||||
|
||||
## Pitch
|
||||
|
||||
Use `toneFrequency` to adjust the pitch without affecting speed. Values range from 0.01 to 2:
|
||||
|
||||
```tsx
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
toneFrequency={1.5} // Higher pitch
|
||||
/>
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
toneFrequency={0.8} // Lower pitch
|
||||
/>
|
||||
```
|
||||
|
||||
Pitch shifting only works during server-side rendering, not in the Remotion Studio preview or in the `<Player />`.
|
||||
@@ -1,134 +0,0 @@
|
||||
---
|
||||
name: calculate-metadata
|
||||
description: Dynamically set composition duration, dimensions, and props
|
||||
metadata:
|
||||
tags: calculateMetadata, duration, dimensions, props, dynamic
|
||||
---
|
||||
|
||||
# Using calculateMetadata
|
||||
|
||||
Use `calculateMetadata` on a `<Composition>` to dynamically set duration, dimensions, and transform props before rendering.
|
||||
|
||||
```tsx
|
||||
<Composition
|
||||
id="MyComp"
|
||||
component={MyComponent}
|
||||
durationInFrames={300}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
defaultProps={{ videoSrc: "https://remotion.media/video.mp4" }}
|
||||
calculateMetadata={calculateMetadata}
|
||||
/>
|
||||
```
|
||||
|
||||
## Setting duration based on a video
|
||||
|
||||
Use the [`getVideoDuration`](./get-video-duration.md) and [`getVideoDimensions`](./get-video-dimensions.md) skills to get the video duration and dimensions:
|
||||
|
||||
```tsx
|
||||
import { CalculateMetadataFunction } from "remotion";
|
||||
import { getVideoDuration } from "./get-video-duration";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
const durationInSeconds = await getVideoDuration(props.videoSrc);
|
||||
|
||||
return {
|
||||
durationInFrames: Math.ceil(durationInSeconds * 30),
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Matching dimensions of a video
|
||||
|
||||
Use the [`getVideoDimensions`](./get-video-dimensions.md) skill to get the video dimensions:
|
||||
|
||||
```tsx
|
||||
import { CalculateMetadataFunction } from "remotion";
|
||||
import { getVideoDuration } from "./get-video-duration";
|
||||
import { getVideoDimensions } from "./get-video-dimensions";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
const dimensions = await getVideoDimensions(props.videoSrc);
|
||||
|
||||
return {
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Setting duration based on multiple videos
|
||||
|
||||
```tsx
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
const metadataPromises = props.videos.map((video) =>
|
||||
getVideoDuration(video.src),
|
||||
);
|
||||
const allMetadata = await Promise.all(metadataPromises);
|
||||
|
||||
const totalDuration = allMetadata.reduce(
|
||||
(sum, durationInSeconds) => sum + durationInSeconds,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
durationInFrames: Math.ceil(totalDuration * 30),
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Setting a default outName
|
||||
|
||||
Set the default output filename based on props:
|
||||
|
||||
```tsx
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
return {
|
||||
defaultOutName: `video-${props.id}.mp4`,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Transforming props
|
||||
|
||||
Fetch data or transform props before rendering:
|
||||
|
||||
```tsx
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
abortSignal,
|
||||
}) => {
|
||||
const response = await fetch(props.dataUrl, { signal: abortSignal });
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
props: {
|
||||
...props,
|
||||
fetchedData: data,
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
The `abortSignal` cancels stale requests when props change in the Studio.
|
||||
|
||||
## Return value
|
||||
|
||||
All fields are optional. Returned values override the `<Composition>` props:
|
||||
|
||||
- `durationInFrames`: Number of frames
|
||||
- `width`: Composition width in pixels
|
||||
- `height`: Composition height in pixels
|
||||
- `fps`: Frames per second
|
||||
- `props`: Transformed props passed to the component
|
||||
- `defaultOutName`: Default output filename
|
||||
- `defaultCodec`: Default codec for rendering
|
||||
@@ -1,75 +0,0 @@
|
||||
---
|
||||
name: can-decode
|
||||
description: Check if a video can be decoded by the browser using Mediabunny
|
||||
metadata:
|
||||
tags: decode, validation, video, audio, compatibility, browser
|
||||
---
|
||||
|
||||
# Checking if a video can be decoded
|
||||
|
||||
Use Mediabunny to check if a video can be decoded by the browser before attempting to play it.
|
||||
|
||||
## The `canDecode()` function
|
||||
|
||||
This function can be copy-pasted into any project.
|
||||
|
||||
```tsx
|
||||
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
|
||||
|
||||
export const canDecode = async (src: string) => {
|
||||
const input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new UrlSource(src, {
|
||||
getRetryDelay: () => null,
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
await input.getFormat();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
if (videoTrack && !(await videoTrack.canDecode())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
if (audioTrack && !(await audioTrack.canDecode())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
const src = "https://remotion.media/video.mp4";
|
||||
const isDecodable = await canDecode(src);
|
||||
|
||||
if (isDecodable) {
|
||||
console.log("Video can be decoded");
|
||||
} else {
|
||||
console.log("Video cannot be decoded by this browser");
|
||||
}
|
||||
```
|
||||
|
||||
## Using with Blob
|
||||
|
||||
For file uploads or drag-and-drop, use `BlobSource`:
|
||||
|
||||
```tsx
|
||||
import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
|
||||
|
||||
export const canDecodeBlob = async (blob: Blob) => {
|
||||
const input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new BlobSource(blob),
|
||||
});
|
||||
|
||||
// Same validation logic as above
|
||||
};
|
||||
```
|
||||
@@ -1,120 +0,0 @@
|
||||
---
|
||||
name: charts
|
||||
description: Chart and data visualization patterns for Remotion. Use when creating bar charts, pie charts, line charts, stock graphs, or any data-driven animations.
|
||||
metadata:
|
||||
tags: charts, data, visualization, bar-chart, pie-chart, line-chart, stock-chart, svg-paths, graphs
|
||||
---
|
||||
|
||||
# Charts in Remotion
|
||||
|
||||
Create charts using React code - HTML, SVG, and D3.js are all supported.
|
||||
|
||||
Disable all animations from third party libraries - they cause flickering.
|
||||
Drive all animations from `useCurrentFrame()`.
|
||||
|
||||
## Bar Chart
|
||||
|
||||
```tsx
|
||||
const STAGGER_DELAY = 5;
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const bars = data.map((item, i) => {
|
||||
const height = spring({
|
||||
frame,
|
||||
fps,
|
||||
delay: i * STAGGER_DELAY,
|
||||
config: { damping: 200 },
|
||||
});
|
||||
return <div style={{ height: height * item.value }} />;
|
||||
});
|
||||
```
|
||||
|
||||
## Pie Chart
|
||||
|
||||
Animate segments using stroke-dashoffset, starting from 12 o'clock:
|
||||
|
||||
```tsx
|
||||
const progress = interpolate(frame, [0, 100], [0, 1]);
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const segmentLength = (value / total) * circumference;
|
||||
const offset = interpolate(progress, [0, 1], [segmentLength, 0]);
|
||||
|
||||
<circle
|
||||
r={radius}
|
||||
cx={center}
|
||||
cy={center}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${segmentLength} ${circumference}`}
|
||||
strokeDashoffset={offset}
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>;
|
||||
```
|
||||
|
||||
## Line Chart / Path Animation
|
||||
|
||||
Use `@remotion/paths` for animating SVG paths (line charts, stock graphs, signatures).
|
||||
|
||||
Install: `npx remotion add @remotion/paths`
|
||||
Docs: https://remotion.dev/docs/paths.md
|
||||
|
||||
### Convert data points to SVG path
|
||||
|
||||
```tsx
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
const generateLinePath = (points: Point[]): string => {
|
||||
if (points.length < 2) return "";
|
||||
return points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
|
||||
};
|
||||
```
|
||||
|
||||
### Draw path with animation
|
||||
|
||||
```tsx
|
||||
import { evolvePath } from "@remotion/paths";
|
||||
|
||||
const path = "M 100 200 L 200 150 L 300 180 L 400 100";
|
||||
const progress = interpolate(frame, [0, 2 * fps], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.out(Easing.quad),
|
||||
});
|
||||
|
||||
const { strokeDasharray, strokeDashoffset } = evolvePath(progress, path);
|
||||
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="#FF3232"
|
||||
strokeWidth={4}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
/>;
|
||||
```
|
||||
|
||||
### Follow path with marker/arrow
|
||||
|
||||
```tsx
|
||||
import {
|
||||
getLength,
|
||||
getPointAtLength,
|
||||
getTangentAtLength,
|
||||
} from "@remotion/paths";
|
||||
|
||||
const pathLength = getLength(path);
|
||||
const point = getPointAtLength(path, progress * pathLength);
|
||||
const tangent = getTangentAtLength(path, progress * pathLength);
|
||||
const angle = Math.atan2(tangent.y, tangent.x);
|
||||
|
||||
<g
|
||||
style={{
|
||||
transform: `translate(${point.x}px, ${point.y}px) rotate(${angle}rad)`,
|
||||
transformOrigin: "0 0",
|
||||
}}
|
||||
>
|
||||
<polygon points="0,0 -20,-10 -20,10" fill="#FF3232" />
|
||||
</g>;
|
||||
```
|
||||
@@ -1,154 +0,0 @@
|
||||
---
|
||||
name: compositions
|
||||
description: Defining compositions, stills, folders, default props and dynamic metadata
|
||||
metadata:
|
||||
tags: composition, still, folder, props, metadata
|
||||
---
|
||||
|
||||
A `<Composition>` defines the component, width, height, fps and duration of a renderable video.
|
||||
|
||||
It normally is placed in the `src/Root.tsx` file.
|
||||
|
||||
```tsx
|
||||
import { Composition } from "remotion";
|
||||
import { MyComposition } from "./MyComposition";
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return (
|
||||
<Composition
|
||||
id="MyComposition"
|
||||
component={MyComposition}
|
||||
durationInFrames={100}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1080}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Default Props
|
||||
|
||||
Pass `defaultProps` to provide initial values for your component.
|
||||
Values must be JSON-serializable (`Date`, `Map`, `Set`, and `staticFile()` are supported).
|
||||
|
||||
```tsx
|
||||
import { Composition } from "remotion";
|
||||
import { MyComposition, MyCompositionProps } from "./MyComposition";
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return (
|
||||
<Composition
|
||||
id="MyComposition"
|
||||
component={MyComposition}
|
||||
durationInFrames={100}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1080}
|
||||
defaultProps={
|
||||
{
|
||||
title: "Hello World",
|
||||
color: "#ff0000",
|
||||
} satisfies MyCompositionProps
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Use `type` declarations for props rather than `interface` to ensure `defaultProps` type safety.
|
||||
|
||||
## Folders
|
||||
|
||||
Use `<Folder>` to organize compositions in the sidebar.
|
||||
Folder names can only contain letters, numbers, and hyphens.
|
||||
|
||||
```tsx
|
||||
import { Composition, Folder } from "remotion";
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return (
|
||||
<>
|
||||
<Folder name="Marketing">
|
||||
<Composition id="Promo" /* ... */ />
|
||||
<Composition id="Ad" /* ... */ />
|
||||
</Folder>
|
||||
<Folder name="Social">
|
||||
<Folder name="Instagram">
|
||||
<Composition id="Story" /* ... */ />
|
||||
<Composition id="Reel" /* ... */ />
|
||||
</Folder>
|
||||
</Folder>
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Stills
|
||||
|
||||
Use `<Still>` for single-frame images. It does not require `durationInFrames` or `fps`.
|
||||
|
||||
```tsx
|
||||
import { Still } from "remotion";
|
||||
import { Thumbnail } from "./Thumbnail";
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return (
|
||||
<Still id="Thumbnail" component={Thumbnail} width={1280} height={720} />
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Calculate Metadata
|
||||
|
||||
Use `calculateMetadata` to make dimensions, duration, or props dynamic based on data.
|
||||
|
||||
```tsx
|
||||
import { Composition, CalculateMetadataFunction } from "remotion";
|
||||
import { MyComposition, MyCompositionProps } from "./MyComposition";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction<
|
||||
MyCompositionProps
|
||||
> = async ({ props, abortSignal }) => {
|
||||
const data = await fetch(`https://api.example.com/video/${props.videoId}`, {
|
||||
signal: abortSignal,
|
||||
}).then((res) => res.json());
|
||||
|
||||
return {
|
||||
durationInFrames: Math.ceil(data.duration * 30),
|
||||
props: {
|
||||
...props,
|
||||
videoUrl: data.url,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return (
|
||||
<Composition
|
||||
id="MyComposition"
|
||||
component={MyComposition}
|
||||
durationInFrames={100} // Placeholder, will be overridden
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1080}
|
||||
defaultProps={{ videoId: "abc123" }}
|
||||
calculateMetadata={calculateMetadata}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
The function can return `props`, `durationInFrames`, `width`, `height`, `fps`, and codec-related defaults. It runs once before rendering begins.
|
||||
|
||||
## Nesting compositions within another
|
||||
|
||||
To add a composition within another composition, you can use the `<Sequence>` component with a `width` and `height` prop to specify the size of the composition.
|
||||
|
||||
```tsx
|
||||
<AbsoluteFill>
|
||||
<Sequence width={COMPOSITION_WIDTH} height={COMPOSITION_HEIGHT}>
|
||||
<CompositionComponent />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
```
|
||||
@@ -1,184 +0,0 @@
|
||||
---
|
||||
name: display-captions
|
||||
description: Displaying captions in Remotion with TikTok-style pages and word highlighting
|
||||
metadata:
|
||||
tags: captions, subtitles, display, tiktok, highlight
|
||||
---
|
||||
|
||||
# Displaying captions in Remotion
|
||||
|
||||
This guide explains how to display captions in Remotion, assuming you already have captions in the [`Caption`](https://www.remotion.dev/docs/captions/caption) format.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Read [Transcribing audio](transcribe-captions.md) for how to generate captions.
|
||||
|
||||
First, the [`@remotion/captions`](https://www.remotion.dev/docs/captions) package needs to be installed.
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/captions
|
||||
```
|
||||
|
||||
## Fetching captions
|
||||
|
||||
First, fetch your captions JSON file. Use [`useDelayRender()`](https://www.remotion.dev/docs/use-delay-render) to hold the render until the captions are loaded:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { AbsoluteFill, staticFile, useDelayRender } from "remotion";
|
||||
import type { Caption } from "@remotion/captions";
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const [captions, setCaptions] = useState<Caption[] | null>(null);
|
||||
const { delayRender, continueRender, cancelRender } = useDelayRender();
|
||||
const [handle] = useState(() => delayRender());
|
||||
|
||||
const fetchCaptions = useCallback(async () => {
|
||||
try {
|
||||
// Assuming captions.json is in the public/ folder.
|
||||
const response = await fetch(staticFile("captions123.json"));
|
||||
const data = await response.json();
|
||||
setCaptions(data);
|
||||
continueRender(handle);
|
||||
} catch (e) {
|
||||
cancelRender(e);
|
||||
}
|
||||
}, [continueRender, cancelRender, handle]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCaptions();
|
||||
}, [fetchCaptions]);
|
||||
|
||||
if (!captions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AbsoluteFill>{/* Render captions here */}</AbsoluteFill>;
|
||||
};
|
||||
```
|
||||
|
||||
## Creating pages
|
||||
|
||||
Use `createTikTokStyleCaptions()` to group captions into pages. The `combineTokensWithinMilliseconds` option controls how many words appear at once:
|
||||
|
||||
```tsx
|
||||
import { useMemo } from "react";
|
||||
import { createTikTokStyleCaptions } from "@remotion/captions";
|
||||
import type { Caption } from "@remotion/captions";
|
||||
|
||||
// How often captions should switch (in milliseconds)
|
||||
// Higher values = more words per page
|
||||
// Lower values = fewer words (more word-by-word)
|
||||
const SWITCH_CAPTIONS_EVERY_MS = 1200;
|
||||
|
||||
const { pages } = useMemo(() => {
|
||||
return createTikTokStyleCaptions({
|
||||
captions,
|
||||
combineTokensWithinMilliseconds: SWITCH_CAPTIONS_EVERY_MS,
|
||||
});
|
||||
}, [captions]);
|
||||
```
|
||||
|
||||
## Rendering with Sequences
|
||||
|
||||
Map over the pages and render each one in a `<Sequence>`. Calculate the start frame and duration from the page timing:
|
||||
|
||||
```tsx
|
||||
import { Sequence, useVideoConfig, AbsoluteFill } from "remotion";
|
||||
import type { TikTokPage } from "@remotion/captions";
|
||||
|
||||
const CaptionedContent: React.FC = () => {
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
{pages.map((page, index) => {
|
||||
const nextPage = pages[index + 1] ?? null;
|
||||
const startFrame = (page.startMs / 1000) * fps;
|
||||
const endFrame = Math.min(
|
||||
nextPage ? (nextPage.startMs / 1000) * fps : Infinity,
|
||||
startFrame + (SWITCH_CAPTIONS_EVERY_MS / 1000) * fps,
|
||||
);
|
||||
const durationInFrames = endFrame - startFrame;
|
||||
|
||||
if (durationInFrames <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Sequence
|
||||
key={index}
|
||||
from={startFrame}
|
||||
durationInFrames={durationInFrames}
|
||||
>
|
||||
<CaptionPage page={page} />
|
||||
</Sequence>
|
||||
);
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## White-space preservation
|
||||
|
||||
The captions are whitespace sensitive. You should include spaces in the `text` field before each word. Use `whiteSpace: "pre"` to preserve the whitespace in the captions.
|
||||
|
||||
## Separate component for captions
|
||||
|
||||
Put captioning logic in a separate component.
|
||||
Make a new file for it.
|
||||
|
||||
## Word highlighting
|
||||
|
||||
A caption page contains `tokens` which you can use to highlight the currently spoken word:
|
||||
|
||||
```tsx
|
||||
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import type { TikTokPage } from "@remotion/captions";
|
||||
|
||||
const HIGHLIGHT_COLOR = "#39E508";
|
||||
|
||||
const CaptionPage: React.FC<{ page: TikTokPage }> = ({ page }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Current time relative to the start of the sequence
|
||||
const currentTimeMs = (frame / fps) * 1000;
|
||||
// Convert to absolute time by adding the page start
|
||||
const absoluteTimeMs = page.startMs + currentTimeMs;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
|
||||
<div style={{ fontSize: 80, fontWeight: "bold", whiteSpace: "pre" }}>
|
||||
{page.tokens.map((token) => {
|
||||
const isActive =
|
||||
token.fromMs <= absoluteTimeMs && token.toMs > absoluteTimeMs;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={token.fromMs}
|
||||
style={{ color: isActive ? HIGHLIGHT_COLOR : "white" }}
|
||||
>
|
||||
{token.text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Display captions alongside video content
|
||||
|
||||
By default, put the captions alongside the video content, so the captions are in sync.
|
||||
For each video, make a new captions JSON file.
|
||||
|
||||
```tsx
|
||||
<AbsoluteFill>
|
||||
<Video src={staticFile("video.mp4")} />
|
||||
<CaptionPage page={page} />
|
||||
</AbsoluteFill>
|
||||
```
|
||||
@@ -1,229 +0,0 @@
|
||||
---
|
||||
name: extract-frames
|
||||
description: Extract frames from videos at specific timestamps using Mediabunny
|
||||
metadata:
|
||||
tags: frames, extract, video, thumbnail, filmstrip, canvas
|
||||
---
|
||||
|
||||
# Extracting frames from videos
|
||||
|
||||
Use Mediabunny to extract frames from videos at specific timestamps. This is useful for generating thumbnails, filmstrips, or processing individual frames.
|
||||
|
||||
## The `extractFrames()` function
|
||||
|
||||
This function can be copy-pasted into any project.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
ALL_FORMATS,
|
||||
Input,
|
||||
UrlSource,
|
||||
VideoSample,
|
||||
VideoSampleSink,
|
||||
} from "mediabunny";
|
||||
|
||||
type Options = {
|
||||
track: { width: number; height: number };
|
||||
container: string;
|
||||
durationInSeconds: number | null;
|
||||
};
|
||||
|
||||
export type ExtractFramesTimestampsInSecondsFn = (
|
||||
options: Options,
|
||||
) => Promise<number[]> | number[];
|
||||
|
||||
export type ExtractFramesProps = {
|
||||
src: string;
|
||||
timestampsInSeconds: number[] | ExtractFramesTimestampsInSecondsFn;
|
||||
onVideoSample: (sample: VideoSample) => void;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export async function extractFrames({
|
||||
src,
|
||||
timestampsInSeconds,
|
||||
onVideoSample,
|
||||
signal,
|
||||
}: ExtractFramesProps): Promise<void> {
|
||||
using input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new UrlSource(src),
|
||||
});
|
||||
|
||||
const [durationInSeconds, format, videoTrack] = await Promise.all([
|
||||
input.computeDuration(),
|
||||
input.getFormat(),
|
||||
input.getPrimaryVideoTrack(),
|
||||
]);
|
||||
|
||||
if (!videoTrack) {
|
||||
throw new Error("No video track found in the input");
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Aborted");
|
||||
}
|
||||
|
||||
const timestamps =
|
||||
typeof timestampsInSeconds === "function"
|
||||
? await timestampsInSeconds({
|
||||
track: {
|
||||
width: videoTrack.displayWidth,
|
||||
height: videoTrack.displayHeight,
|
||||
},
|
||||
container: format.name,
|
||||
durationInSeconds,
|
||||
})
|
||||
: timestampsInSeconds;
|
||||
|
||||
if (timestamps.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Aborted");
|
||||
}
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
|
||||
for await (using videoSample of sink.samplesAtTimestamps(timestamps)) {
|
||||
if (signal?.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!videoSample) {
|
||||
continue;
|
||||
}
|
||||
|
||||
onVideoSample(videoSample);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Basic usage
|
||||
|
||||
Extract frames at specific timestamps:
|
||||
|
||||
```tsx
|
||||
await extractFrames({
|
||||
src: "https://remotion.media/video.mp4",
|
||||
timestampsInSeconds: [0, 1, 2, 3, 4],
|
||||
onVideoSample: (sample) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = sample.displayWidth;
|
||||
canvas.height = sample.displayHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
sample.draw(ctx!, 0, 0);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Creating a filmstrip
|
||||
|
||||
Use a callback function to dynamically calculate timestamps based on video metadata:
|
||||
|
||||
```tsx
|
||||
const canvasWidth = 500;
|
||||
const canvasHeight = 80;
|
||||
const fromSeconds = 0;
|
||||
const toSeconds = 10;
|
||||
|
||||
await extractFrames({
|
||||
src: "https://remotion.media/video.mp4",
|
||||
timestampsInSeconds: async ({ track, durationInSeconds }) => {
|
||||
const aspectRatio = track.width / track.height;
|
||||
const amountOfFramesFit = Math.ceil(
|
||||
canvasWidth / (canvasHeight * aspectRatio),
|
||||
);
|
||||
const segmentDuration = toSeconds - fromSeconds;
|
||||
const timestamps: number[] = [];
|
||||
|
||||
for (let i = 0; i < amountOfFramesFit; i++) {
|
||||
timestamps.push(
|
||||
fromSeconds + (segmentDuration / amountOfFramesFit) * (i + 0.5),
|
||||
);
|
||||
}
|
||||
|
||||
return timestamps;
|
||||
},
|
||||
onVideoSample: (sample) => {
|
||||
console.log(`Frame at ${sample.timestamp}s`);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = sample.displayWidth;
|
||||
canvas.height = sample.displayHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
sample.draw(ctx!, 0, 0);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Cancellation with AbortSignal
|
||||
|
||||
Cancel frame extraction after a timeout:
|
||||
|
||||
```tsx
|
||||
const controller = new AbortController();
|
||||
|
||||
setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
await extractFrames({
|
||||
src: "https://remotion.media/video.mp4",
|
||||
timestampsInSeconds: [0, 1, 2, 3, 4],
|
||||
onVideoSample: (sample) => {
|
||||
using frame = sample;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = frame.displayWidth;
|
||||
canvas.height = frame.displayHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
frame.draw(ctx!, 0, 0);
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
console.log("Frame extraction complete!");
|
||||
} catch (error) {
|
||||
console.error("Frame extraction was aborted or failed:", error);
|
||||
}
|
||||
```
|
||||
|
||||
## Timeout with Promise.race
|
||||
|
||||
```tsx
|
||||
const controller = new AbortController();
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error("Frame extraction timed out after 10 seconds"));
|
||||
}, 10000);
|
||||
|
||||
controller.signal.addEventListener("abort", () => clearTimeout(timeoutId), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
extractFrames({
|
||||
src: "https://remotion.media/video.mp4",
|
||||
timestampsInSeconds: [0, 1, 2, 3, 4],
|
||||
onVideoSample: (sample) => {
|
||||
using frame = sample;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = frame.displayWidth;
|
||||
canvas.height = frame.displayHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
frame.draw(ctx!, 0, 0);
|
||||
},
|
||||
signal: controller.signal,
|
||||
}),
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
console.log("Frame extraction complete!");
|
||||
} catch (error) {
|
||||
console.error("Frame extraction was aborted or failed:", error);
|
||||
}
|
||||
```
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: ffmpeg
|
||||
description: Using FFmpeg and FFprobe in Remotion
|
||||
metadata:
|
||||
tags: ffmpeg, ffprobe, video, trimming
|
||||
---
|
||||
|
||||
## FFmpeg in Remotion
|
||||
|
||||
`ffmpeg` and `ffprobe` do not need to be installed. They are available via the `bunx remotion ffmpeg` and `bunx remotion ffprobe`:
|
||||
|
||||
```bash
|
||||
bunx remotion ffmpeg -i input.mp4 output.mp3
|
||||
bunx remotion ffprobe input.mp4
|
||||
```
|
||||
|
||||
### Trimming videos
|
||||
|
||||
You have 2 options for trimming videos:
|
||||
|
||||
1. Use the FFMpeg command line. You MUST re-encode the video to avoid frozen frames at the start of the video.
|
||||
|
||||
```bash
|
||||
# Re-encodes from the exact frame
|
||||
bunx remotion ffmpeg -ss 00:00:05 -i public/input.mp4 -to 00:00:10 -c:v libx264 -c:a aac public/output.mp4
|
||||
```
|
||||
|
||||
2. Use the `trimBefore` and `trimAfter` props of the `<Video>` component. The benefit is that this is non-destructive and you can change the trim at any time.
|
||||
|
||||
```tsx
|
||||
import { Video } from "@remotion/media";
|
||||
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
trimBefore={5 * fps}
|
||||
trimAfter={10 * fps}
|
||||
/>;
|
||||
```
|
||||
@@ -1,152 +0,0 @@
|
||||
---
|
||||
name: fonts
|
||||
description: Loading Google Fonts and local fonts in Remotion
|
||||
metadata:
|
||||
tags: fonts, google-fonts, typography, text
|
||||
---
|
||||
|
||||
# Using fonts in Remotion
|
||||
|
||||
## Google Fonts with @remotion/google-fonts
|
||||
|
||||
The recommended way to use Google Fonts. It's type-safe and automatically blocks rendering until the font is ready.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
First, the @remotion/google-fonts package needs to be installed.
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/google-fonts # If project uses npm
|
||||
bunx remotion add @remotion/google-fonts # If project uses bun
|
||||
yarn remotion add @remotion/google-fonts # If project uses yarn
|
||||
pnpm exec remotion add @remotion/google-fonts # If project uses pnpm
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Lobster";
|
||||
|
||||
const { fontFamily } = loadFont();
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <div style={{ fontFamily }}>Hello World</div>;
|
||||
};
|
||||
```
|
||||
|
||||
Preferrably, specify only needed weights and subsets to reduce file size:
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Roboto";
|
||||
|
||||
const { fontFamily } = loadFont("normal", {
|
||||
weights: ["400", "700"],
|
||||
subsets: ["latin"],
|
||||
});
|
||||
```
|
||||
|
||||
### Waiting for font to load
|
||||
|
||||
Use `waitUntilDone()` if you need to know when the font is ready:
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Lobster";
|
||||
|
||||
const { fontFamily, waitUntilDone } = loadFont();
|
||||
|
||||
await waitUntilDone();
|
||||
```
|
||||
|
||||
## Local fonts with @remotion/fonts
|
||||
|
||||
For local font files, use the `@remotion/fonts` package.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
First, install @remotion/fonts:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/fonts # If project uses npm
|
||||
bunx remotion add @remotion/fonts # If project uses bun
|
||||
yarn remotion add @remotion/fonts # If project uses yarn
|
||||
pnpm exec remotion add @remotion/fonts # If project uses pnpm
|
||||
```
|
||||
|
||||
### Loading a local font
|
||||
|
||||
Place your font file in the `public/` folder and use `loadFont()`:
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/fonts";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
await loadFont({
|
||||
family: "MyFont",
|
||||
url: staticFile("MyFont-Regular.woff2"),
|
||||
});
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <div style={{ fontFamily: "MyFont" }}>Hello World</div>;
|
||||
};
|
||||
```
|
||||
|
||||
### Loading multiple weights
|
||||
|
||||
Load each weight separately with the same family name:
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/fonts";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
await Promise.all([
|
||||
loadFont({
|
||||
family: "Inter",
|
||||
url: staticFile("Inter-Regular.woff2"),
|
||||
weight: "400",
|
||||
}),
|
||||
loadFont({
|
||||
family: "Inter",
|
||||
url: staticFile("Inter-Bold.woff2"),
|
||||
weight: "700",
|
||||
}),
|
||||
]);
|
||||
```
|
||||
|
||||
### Available options
|
||||
|
||||
```tsx
|
||||
loadFont({
|
||||
family: "MyFont", // Required: name to use in CSS
|
||||
url: staticFile("font.woff2"), // Required: font file URL
|
||||
format: "woff2", // Optional: auto-detected from extension
|
||||
weight: "400", // Optional: font weight
|
||||
style: "normal", // Optional: normal or italic
|
||||
display: "block", // Optional: font-display behavior
|
||||
});
|
||||
```
|
||||
|
||||
## Using in components
|
||||
|
||||
Call `loadFont()` at the top level of your component or in a separate file that's imported early:
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Montserrat";
|
||||
|
||||
const { fontFamily } = loadFont("normal", {
|
||||
weights: ["400", "700"],
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const Title: React.FC<{ text: string }> = ({ text }) => {
|
||||
return (
|
||||
<h1
|
||||
style={{
|
||||
fontFamily,
|
||||
fontSize: 80,
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
```
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
name: get-audio-duration
|
||||
description: Getting the duration of an audio file in seconds with Mediabunny
|
||||
metadata:
|
||||
tags: duration, audio, length, time, seconds, mp3, wav
|
||||
---
|
||||
|
||||
# Getting audio duration with Mediabunny
|
||||
|
||||
Mediabunny can extract the duration of an audio file. It works in browser, Node.js, and Bun environments.
|
||||
|
||||
## Getting audio duration
|
||||
|
||||
```tsx title="get-audio-duration.ts"
|
||||
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
|
||||
|
||||
export const getAudioDuration = async (src: string) => {
|
||||
const input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new UrlSource(src, {
|
||||
getRetryDelay: () => null,
|
||||
}),
|
||||
});
|
||||
|
||||
const durationInSeconds = await input.computeDuration();
|
||||
return durationInSeconds;
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
const duration = await getAudioDuration("https://remotion.media/audio.mp3");
|
||||
console.log(duration); // e.g. 180.5 (seconds)
|
||||
```
|
||||
|
||||
## Using with staticFile in Remotion
|
||||
|
||||
Make sure to wrap the file path in `staticFile()`:
|
||||
|
||||
```tsx
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
const duration = await getAudioDuration(staticFile("audio.mp3"));
|
||||
```
|
||||
|
||||
## In Node.js and Bun
|
||||
|
||||
Use `FileSource` instead of `UrlSource`:
|
||||
|
||||
```tsx
|
||||
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
|
||||
|
||||
const input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new FileSource(file), // File object from input or drag-drop
|
||||
});
|
||||
```
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
name: get-video-dimensions
|
||||
description: Getting the width and height of a video file with Mediabunny
|
||||
metadata:
|
||||
tags: dimensions, width, height, resolution, size, video
|
||||
---
|
||||
|
||||
# Getting video dimensions with Mediabunny
|
||||
|
||||
Mediabunny can extract the width and height of a video file. It works in browser, Node.js, and Bun environments.
|
||||
|
||||
## Getting video dimensions
|
||||
|
||||
```tsx
|
||||
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
|
||||
|
||||
export const getVideoDimensions = async (src: string) => {
|
||||
const input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new UrlSource(src, {
|
||||
getRetryDelay: () => null,
|
||||
}),
|
||||
});
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
if (!videoTrack) {
|
||||
throw new Error("No video track found");
|
||||
}
|
||||
|
||||
return {
|
||||
width: videoTrack.displayWidth,
|
||||
height: videoTrack.displayHeight,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
const dimensions = await getVideoDimensions("https://remotion.media/video.mp4");
|
||||
console.log(dimensions.width); // e.g. 1920
|
||||
console.log(dimensions.height); // e.g. 1080
|
||||
```
|
||||
|
||||
## Using with local files
|
||||
|
||||
For local files, use `FileSource` instead of `UrlSource`:
|
||||
|
||||
```tsx
|
||||
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
|
||||
|
||||
const input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new FileSource(file), // File object from input or drag-drop
|
||||
});
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const width = videoTrack.displayWidth;
|
||||
const height = videoTrack.displayHeight;
|
||||
```
|
||||
|
||||
## Using with staticFile in Remotion
|
||||
|
||||
```tsx
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
const dimensions = await getVideoDimensions(staticFile("video.mp4"));
|
||||
```
|
||||
@@ -1,60 +0,0 @@
|
||||
---
|
||||
name: get-video-duration
|
||||
description: Getting the duration of a video file in seconds with Mediabunny
|
||||
metadata:
|
||||
tags: duration, video, length, time, seconds
|
||||
---
|
||||
|
||||
# Getting video duration with Mediabunny
|
||||
|
||||
Mediabunny can extract the duration of a video file. It works in browser, Node.js, and Bun environments.
|
||||
|
||||
## Getting video duration
|
||||
|
||||
```tsx
|
||||
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
|
||||
|
||||
export const getVideoDuration = async (src: string) => {
|
||||
const input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new UrlSource(src, {
|
||||
getRetryDelay: () => null,
|
||||
}),
|
||||
});
|
||||
|
||||
const durationInSeconds = await input.computeDuration();
|
||||
return durationInSeconds;
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
const duration = await getVideoDuration("https://remotion.media/video.mp4");
|
||||
console.log(duration); // e.g. 10.5 (seconds)
|
||||
```
|
||||
|
||||
## Video files from the public/ directory
|
||||
|
||||
Make sure to wrap the file path in `staticFile()`:
|
||||
|
||||
```tsx
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
const duration = await getVideoDuration(staticFile("video.mp4"));
|
||||
```
|
||||
|
||||
## In Node.js and Bun
|
||||
|
||||
Use `FileSource` instead of `UrlSource`:
|
||||
|
||||
```tsx
|
||||
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
|
||||
|
||||
const input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new FileSource(file), // File object from input or drag-drop
|
||||
});
|
||||
|
||||
const durationInSeconds = await input.computeDuration();
|
||||
```
|
||||
@@ -1,141 +0,0 @@
|
||||
---
|
||||
name: gif
|
||||
description: Displaying GIFs, APNG, AVIF and WebP in Remotion
|
||||
metadata:
|
||||
tags: gif, animation, images, animated, apng, avif, webp
|
||||
---
|
||||
|
||||
# Using Animated images in Remotion
|
||||
|
||||
## Basic usage
|
||||
|
||||
Use `<AnimatedImage>` to display a GIF, APNG, AVIF or WebP image synchronized with Remotion's timeline:
|
||||
|
||||
```tsx
|
||||
import { AnimatedImage, staticFile } from "remotion";
|
||||
|
||||
export const MyComposition = () => {
|
||||
return (
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} />
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Remote URLs are also supported (must have CORS enabled):
|
||||
|
||||
```tsx
|
||||
<AnimatedImage
|
||||
src="https://example.com/animation.gif"
|
||||
width={500}
|
||||
height={500}
|
||||
/>
|
||||
```
|
||||
|
||||
## Sizing and fit
|
||||
|
||||
Control how the image fills its container with the `fit` prop:
|
||||
|
||||
```tsx
|
||||
// Stretch to fill (default)
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="fill" />
|
||||
|
||||
// Maintain aspect ratio, fit inside container
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="contain" />
|
||||
|
||||
// Fill container, crop if needed
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="cover" />
|
||||
```
|
||||
|
||||
## Playback speed
|
||||
|
||||
Use `playbackRate` to control the animation speed:
|
||||
|
||||
```tsx
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} playbackRate={2} /> {/* 2x speed */}
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} playbackRate={0.5} /> {/* Half speed */}
|
||||
```
|
||||
|
||||
## Looping behavior
|
||||
|
||||
Control what happens when the animation finishes:
|
||||
|
||||
```tsx
|
||||
// Loop indefinitely (default)
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="loop" />
|
||||
|
||||
// Play once, show final frame
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="pause-after-finish" />
|
||||
|
||||
// Play once, then clear canvas
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="clear-after-finish" />
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
Use the `style` prop for additional CSS (use `width` and `height` props for sizing):
|
||||
|
||||
```tsx
|
||||
<AnimatedImage
|
||||
src={staticFile("animation.gif")}
|
||||
width={500}
|
||||
height={500}
|
||||
style={{
|
||||
borderRadius: 20,
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
left: 50,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Getting GIF duration
|
||||
|
||||
Use `getGifDurationInSeconds()` from `@remotion/gif` to get the duration of a GIF.
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/gif
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { getGifDurationInSeconds } from "@remotion/gif";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
const duration = await getGifDurationInSeconds(staticFile("animation.gif"));
|
||||
console.log(duration); // e.g. 2.5
|
||||
```
|
||||
|
||||
This is useful for setting the composition duration to match the GIF:
|
||||
|
||||
```tsx
|
||||
import { getGifDurationInSeconds } from "@remotion/gif";
|
||||
import { staticFile, CalculateMetadataFunction } from "remotion";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction = async () => {
|
||||
const duration = await getGifDurationInSeconds(staticFile("animation.gif"));
|
||||
return {
|
||||
durationInFrames: Math.ceil(duration * 30),
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Alternative
|
||||
|
||||
If `<AnimatedImage>` does not work (only supported in Chrome and Firefox), you can use `<Gif>` from `@remotion/gif` instead.
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/gif # If project uses npm
|
||||
bunx remotion add @remotion/gif # If project uses bun
|
||||
yarn remotion add @remotion/gif # If project uses yarn
|
||||
pnpm exec remotion add @remotion/gif # If project uses pnpm
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { Gif } from "@remotion/gif";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <Gif src={staticFile("animation.gif")} width={500} height={500} />;
|
||||
};
|
||||
```
|
||||
|
||||
The `<Gif>` component has the same props as `<AnimatedImage>` but only supports GIF files.
|
||||
@@ -1,134 +0,0 @@
|
||||
---
|
||||
name: images
|
||||
description: Embedding images in Remotion using the <Img> component
|
||||
metadata:
|
||||
tags: images, img, staticFile, png, jpg, svg, webp
|
||||
---
|
||||
|
||||
# Using images in Remotion
|
||||
|
||||
## The `<Img>` component
|
||||
|
||||
Always use the `<Img>` component from `remotion` to display images:
|
||||
|
||||
```tsx
|
||||
import { Img, staticFile } from "remotion";
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <Img src={staticFile("photo.png")} />;
|
||||
};
|
||||
```
|
||||
|
||||
## Important restrictions
|
||||
|
||||
**You MUST use the `<Img>` component from `remotion`.** Do not use:
|
||||
|
||||
- Native HTML `<img>` elements
|
||||
- Next.js `<Image>` component
|
||||
- CSS `background-image`
|
||||
|
||||
The `<Img>` component ensures images are fully loaded before rendering, preventing flickering and blank frames during video export.
|
||||
|
||||
## Local images with staticFile()
|
||||
|
||||
Place images in the `public/` folder and use `staticFile()` to reference them:
|
||||
|
||||
```
|
||||
my-video/
|
||||
├─ public/
|
||||
│ ├─ logo.png
|
||||
│ ├─ avatar.jpg
|
||||
│ └─ icon.svg
|
||||
├─ src/
|
||||
├─ package.json
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { Img, staticFile } from "remotion";
|
||||
|
||||
<Img src={staticFile("logo.png")} />;
|
||||
```
|
||||
|
||||
## Remote images
|
||||
|
||||
Remote URLs can be used directly without `staticFile()`:
|
||||
|
||||
```tsx
|
||||
<Img src="https://example.com/image.png" />
|
||||
```
|
||||
|
||||
Ensure remote images have CORS enabled.
|
||||
|
||||
For animated GIFs, use the `<Gif>` component from `@remotion/gif` instead.
|
||||
|
||||
## Sizing and positioning
|
||||
|
||||
Use the `style` prop to control size and position:
|
||||
|
||||
```tsx
|
||||
<Img
|
||||
src={staticFile("photo.png")}
|
||||
style={{
|
||||
width: 500,
|
||||
height: 300,
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
left: 50,
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Dynamic image paths
|
||||
|
||||
Use template literals for dynamic file references:
|
||||
|
||||
```tsx
|
||||
import { Img, staticFile, useCurrentFrame } from "remotion";
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
// Image sequence
|
||||
<Img src={staticFile(`frames/frame${frame}.png`)} />
|
||||
|
||||
// Selecting based on props
|
||||
<Img src={staticFile(`avatars/${props.userId}.png`)} />
|
||||
|
||||
// Conditional images
|
||||
<Img src={staticFile(`icons/${isActive ? "active" : "inactive"}.svg`)} />
|
||||
```
|
||||
|
||||
This pattern is useful for:
|
||||
|
||||
- Image sequences (frame-by-frame animations)
|
||||
- User-specific avatars or profile images
|
||||
- Theme-based icons
|
||||
- State-dependent graphics
|
||||
|
||||
## Getting image dimensions
|
||||
|
||||
Use `getImageDimensions()` to get the dimensions of an image:
|
||||
|
||||
```tsx
|
||||
import { getImageDimensions, staticFile } from "remotion";
|
||||
|
||||
const { width, height } = await getImageDimensions(staticFile("photo.png"));
|
||||
```
|
||||
|
||||
This is useful for calculating aspect ratios or sizing compositions:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
getImageDimensions,
|
||||
staticFile,
|
||||
CalculateMetadataFunction,
|
||||
} from "remotion";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction = async () => {
|
||||
const { width, height } = await getImageDimensions(staticFile("photo.png"));
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
```
|
||||
@@ -1,69 +0,0 @@
|
||||
---
|
||||
name: import-srt-captions
|
||||
description: Importing .srt subtitle files into Remotion using @remotion/captions
|
||||
metadata:
|
||||
tags: captions, subtitles, srt, import, parse
|
||||
---
|
||||
|
||||
# Importing .srt subtitles into Remotion
|
||||
|
||||
If you have an existing `.srt` subtitle file, you can import it into Remotion using `parseSrt()` from `@remotion/captions`.
|
||||
|
||||
If you don't have a .srt file, read [Transcribing audio](transcribe-captions.md) for how to generate captions instead.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the @remotion/captions package needs to be installed.
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/captions # If project uses npm
|
||||
bunx remotion add @remotion/captions # If project uses bun
|
||||
yarn remotion add @remotion/captions # If project uses yarn
|
||||
pnpm exec remotion add @remotion/captions # If project uses pnpm
|
||||
```
|
||||
|
||||
## Reading an .srt file
|
||||
|
||||
Use `staticFile()` to reference an `.srt` file in your `public` folder, then fetch and parse it:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { AbsoluteFill, staticFile, useDelayRender } from "remotion";
|
||||
import { parseSrt } from "@remotion/captions";
|
||||
import type { Caption } from "@remotion/captions";
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const [captions, setCaptions] = useState<Caption[] | null>(null);
|
||||
const { delayRender, continueRender, cancelRender } = useDelayRender();
|
||||
const [handle] = useState(() => delayRender());
|
||||
|
||||
const fetchCaptions = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(staticFile("subtitles.srt"));
|
||||
const text = await response.text();
|
||||
const { captions: parsed } = parseSrt({ input: text });
|
||||
setCaptions(parsed);
|
||||
continueRender(handle);
|
||||
} catch (e) {
|
||||
cancelRender(e);
|
||||
}
|
||||
}, [continueRender, cancelRender, handle]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCaptions();
|
||||
}, [fetchCaptions]);
|
||||
|
||||
if (!captions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AbsoluteFill>{/* Use captions here */}</AbsoluteFill>;
|
||||
};
|
||||
```
|
||||
|
||||
Remote URLs are also supported - you can `fetch()` a remote file via URL instead of using `staticFile()`.
|
||||
|
||||
## Using imported captions
|
||||
|
||||
Once parsed, the captions are in the `Caption` format and can be used with all `@remotion/captions` utilities.
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
name: light-leaks
|
||||
description: Light leak overlay effects for Remotion using @remotion/light-leaks.
|
||||
metadata:
|
||||
tags: light-leaks, overlays, effects, transitions
|
||||
---
|
||||
|
||||
## Light Leaks
|
||||
|
||||
This only works from Remotion 4.0.415 and up. Use `npx remotion versions` to check your Remotion version and `npx remotion upgrade` to upgrade your Remotion version.
|
||||
|
||||
`<LightLeak>` from `@remotion/light-leaks` renders a WebGL-based light leak effect. It reveals during the first half of its duration and retracts during the second half.
|
||||
|
||||
Typically used inside a `<TransitionSeries.Overlay>` to play over the cut point between two scenes. See the **transitions** rule for `<TransitionSeries>` and overlay usage.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/light-leaks
|
||||
```
|
||||
|
||||
## Basic usage with TransitionSeries
|
||||
|
||||
```tsx
|
||||
import { TransitionSeries } from "@remotion/transitions";
|
||||
import { LightLeak } from "@remotion/light-leaks";
|
||||
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneA />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Overlay durationInFrames={30}>
|
||||
<LightLeak />
|
||||
</TransitionSeries.Overlay>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneB />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>;
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
- `durationInFrames?` — defaults to the parent sequence/composition duration. The effect reveals during the first half and retracts during the second half.
|
||||
- `seed?` — determines the shape of the light leak pattern. Different seeds produce different patterns. Default: `0`.
|
||||
- `hueShift?` — rotates the hue in degrees (`0`–`360`). Default: `0` (yellow-to-orange). `120` = green, `240` = blue.
|
||||
|
||||
## Customizing the look
|
||||
|
||||
```tsx
|
||||
import { LightLeak } from "@remotion/light-leaks";
|
||||
|
||||
// Blue-tinted light leak with a different pattern
|
||||
<LightLeak seed={5} hueShift={240} />;
|
||||
|
||||
// Green-tinted light leak
|
||||
<LightLeak seed={2} hueShift={120} />;
|
||||
```
|
||||
|
||||
## Standalone usage
|
||||
|
||||
`<LightLeak>` can also be used outside of `<TransitionSeries>`, for example as a decorative overlay in any composition:
|
||||
|
||||
```tsx
|
||||
import { AbsoluteFill } from "remotion";
|
||||
import { LightLeak } from "@remotion/light-leaks";
|
||||
|
||||
const MyComp: React.FC = () => (
|
||||
<AbsoluteFill>
|
||||
<MyContent />
|
||||
<LightLeak durationInFrames={60} seed={3} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
```
|
||||
@@ -1,70 +0,0 @@
|
||||
---
|
||||
name: lottie
|
||||
description: Embedding Lottie animations in Remotion.
|
||||
metadata:
|
||||
category: Animation
|
||||
---
|
||||
|
||||
# Using Lottie Animations in Remotion
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the @remotion/lottie package needs to be installed.
|
||||
If it is not, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/lottie # If project uses npm
|
||||
bunx remotion add @remotion/lottie # If project uses bun
|
||||
yarn remotion add @remotion/lottie # If project uses yarn
|
||||
pnpm exec remotion add @remotion/lottie # If project uses pnpm
|
||||
```
|
||||
|
||||
## Displaying a Lottie file
|
||||
|
||||
To import a Lottie animation:
|
||||
|
||||
- Fetch the Lottie asset
|
||||
- Wrap the loading process in `delayRender()` and `continueRender()`
|
||||
- Save the animation data in a state
|
||||
- Render the Lottie animation using the `Lottie` component from the `@remotion/lottie` package
|
||||
|
||||
```tsx
|
||||
import { Lottie, LottieAnimationData } from "@remotion/lottie";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cancelRender, continueRender, delayRender } from "remotion";
|
||||
|
||||
export const MyAnimation = () => {
|
||||
const [handle] = useState(() => delayRender("Loading Lottie animation"));
|
||||
|
||||
const [animationData, setAnimationData] =
|
||||
useState<LottieAnimationData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("https://assets4.lottiefiles.com/packages/lf20_zyquagfl.json")
|
||||
.then((data) => data.json())
|
||||
.then((json) => {
|
||||
setAnimationData(json);
|
||||
continueRender(handle);
|
||||
})
|
||||
.catch((err) => {
|
||||
cancelRender(err);
|
||||
});
|
||||
}, [handle]);
|
||||
|
||||
if (!animationData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Lottie animationData={animationData} />;
|
||||
};
|
||||
```
|
||||
|
||||
## Styling and animating
|
||||
|
||||
Lottie supports the `style` prop to allow styles and animations:
|
||||
|
||||
```tsx
|
||||
return (
|
||||
<Lottie animationData={animationData} style={{ width: 400, height: 400 }} />
|
||||
);
|
||||
```
|
||||
@@ -1,412 +0,0 @@
|
||||
---
|
||||
name: maps
|
||||
description: Make map animations with Mapbox
|
||||
metadata:
|
||||
tags: map, map animation, mapbox
|
||||
---
|
||||
|
||||
Maps can be added to a Remotion video with Mapbox.
|
||||
The [Mapbox documentation](https://docs.mapbox.com/mapbox-gl-js/api/) has the API reference.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Mapbox and `@turf/turf` need to be installed.
|
||||
|
||||
Search the project for lockfiles and run the correct command depending on the package manager:
|
||||
|
||||
If `package-lock.json` is found, use the following command:
|
||||
|
||||
```bash
|
||||
npm i mapbox-gl @turf/turf @types/mapbox-gl
|
||||
```
|
||||
|
||||
If `bun.lock` is found, use the following command:
|
||||
|
||||
```bash
|
||||
bun i mapbox-gl @turf/turf @types/mapbox-gl
|
||||
```
|
||||
|
||||
If `yarn.lock` is found, use the following command:
|
||||
|
||||
```bash
|
||||
yarn add mapbox-gl @turf/turf @types/mapbox-gl
|
||||
```
|
||||
|
||||
If `pnpm-lock.yaml` is found, use the following command:
|
||||
|
||||
```bash
|
||||
pnpm i mapbox-gl @turf/turf @types/mapbox-gl
|
||||
```
|
||||
|
||||
The user needs to create a free Mapbox account and create an access token by visiting https://console.mapbox.com/account/access-tokens/.
|
||||
|
||||
The mapbox token needs to be added to the `.env` file:
|
||||
|
||||
```txt title=".env"
|
||||
REMOTION_MAPBOX_TOKEN==pk.your-mapbox-access-token
|
||||
```
|
||||
|
||||
## Adding a map
|
||||
|
||||
Here is a basic example of a map in Remotion.
|
||||
|
||||
```tsx
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AbsoluteFill, useDelayRender, useVideoConfig } from "remotion";
|
||||
import mapboxgl, { Map } from "mapbox-gl";
|
||||
|
||||
export const lineCoordinates = [
|
||||
[6.56158447265625, 46.059891147620725],
|
||||
[6.5691375732421875, 46.05679376154153],
|
||||
[6.5842437744140625, 46.05059898938315],
|
||||
[6.594886779785156, 46.04702502069337],
|
||||
[6.601066589355469, 46.0460718554722],
|
||||
[6.6089630126953125, 46.0365370783104],
|
||||
[6.6185760498046875, 46.018420689207964],
|
||||
];
|
||||
|
||||
mapboxgl.accessToken = process.env.REMOTION_MAPBOX_TOKEN as string;
|
||||
|
||||
export const MyComposition = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { delayRender, continueRender } = useDelayRender();
|
||||
|
||||
const { width, height } = useVideoConfig();
|
||||
const [handle] = useState(() => delayRender("Loading map..."));
|
||||
const [map, setMap] = useState<Map | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const _map = new Map({
|
||||
container: ref.current!,
|
||||
zoom: 11.53,
|
||||
center: [6.5615, 46.0598],
|
||||
pitch: 65,
|
||||
bearing: 0,
|
||||
style: "mapbox://styles/mapbox/standard",
|
||||
interactive: false,
|
||||
fadeDuration: 0,
|
||||
});
|
||||
|
||||
_map.on("style.load", () => {
|
||||
// Hide all features from the Mapbox Standard style
|
||||
const hideFeatures = [
|
||||
"showRoadsAndTransit",
|
||||
"showRoads",
|
||||
"showTransit",
|
||||
"showPedestrianRoads",
|
||||
"showRoadLabels",
|
||||
"showTransitLabels",
|
||||
"showPlaceLabels",
|
||||
"showPointOfInterestLabels",
|
||||
"showPointsOfInterest",
|
||||
"showAdminBoundaries",
|
||||
"showLandmarkIcons",
|
||||
"showLandmarkIconLabels",
|
||||
"show3dObjects",
|
||||
"show3dBuildings",
|
||||
"show3dTrees",
|
||||
"show3dLandmarks",
|
||||
"show3dFacades",
|
||||
];
|
||||
for (const feature of hideFeatures) {
|
||||
_map.setConfigProperty("basemap", feature, false);
|
||||
}
|
||||
|
||||
_map.setConfigProperty("basemap", "colorTrunks", "rgba(0, 0, 0, 0)");
|
||||
|
||||
_map.addSource("trace", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: lineCoordinates,
|
||||
},
|
||||
},
|
||||
});
|
||||
_map.addLayer({
|
||||
type: "line",
|
||||
source: "trace",
|
||||
id: "line",
|
||||
paint: {
|
||||
"line-color": "black",
|
||||
"line-width": 5,
|
||||
},
|
||||
layout: {
|
||||
"line-cap": "round",
|
||||
"line-join": "round",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
_map.on("load", () => {
|
||||
continueRender(handle);
|
||||
setMap(_map);
|
||||
});
|
||||
}, [handle, lineCoordinates]);
|
||||
|
||||
const style: React.CSSProperties = useMemo(
|
||||
() => ({ width, height, position: "absolute" }),
|
||||
[width, height],
|
||||
);
|
||||
|
||||
return <AbsoluteFill ref={ref} style={style} />;
|
||||
};
|
||||
```
|
||||
|
||||
The following is important in Remotion:
|
||||
|
||||
- Animations must be driven by `useCurrentFrame()` and animations that Mapbox brings itself should be disabled. For example, the `fadeDuration` prop should be set to `0`, `interactive` should be set to `false`, etc.
|
||||
- Loading the map should be delayed using `useDelayRender()` and the map should be set to `null` until it is loaded.
|
||||
- The element containing the ref MUST have an explicit width and height and `position: "absolute"`.
|
||||
- Do not add a `_map.remove();` cleanup function.
|
||||
|
||||
## Drawing lines
|
||||
|
||||
Unless I request it, do not add a glow effect to the lines.
|
||||
Unless I request it, do not add additional points to the lines.
|
||||
|
||||
## Map style
|
||||
|
||||
By default, use the `mapbox://styles/mapbox/standard` style.
|
||||
Hide the labels from the base map style.
|
||||
|
||||
Unless I request otherwise, remove all features from the Mapbox Standard style.
|
||||
|
||||
```tsx
|
||||
// Hide all features from the Mapbox Standard style
|
||||
const hideFeatures = [
|
||||
"showRoadsAndTransit",
|
||||
"showRoads",
|
||||
"showTransit",
|
||||
"showPedestrianRoads",
|
||||
"showRoadLabels",
|
||||
"showTransitLabels",
|
||||
"showPlaceLabels",
|
||||
"showPointOfInterestLabels",
|
||||
"showPointsOfInterest",
|
||||
"showAdminBoundaries",
|
||||
"showLandmarkIcons",
|
||||
"showLandmarkIconLabels",
|
||||
"show3dObjects",
|
||||
"show3dBuildings",
|
||||
"show3dTrees",
|
||||
"show3dLandmarks",
|
||||
"show3dFacades",
|
||||
];
|
||||
for (const feature of hideFeatures) {
|
||||
_map.setConfigProperty("basemap", feature, false);
|
||||
}
|
||||
|
||||
_map.setConfigProperty("basemap", "colorMotorways", "transparent");
|
||||
_map.setConfigProperty("basemap", "colorRoads", "transparent");
|
||||
_map.setConfigProperty("basemap", "colorTrunks", "transparent");
|
||||
```
|
||||
|
||||
## Animating the camera
|
||||
|
||||
You can animate the camera along the line by adding a `useEffect` hook that updates the camera position based on the current frame.
|
||||
|
||||
Unless I ask for it, do not jump between camera angles.
|
||||
|
||||
```tsx
|
||||
import * as turf from "@turf/turf";
|
||||
import { interpolate } from "remotion";
|
||||
import { Easing } from "remotion";
|
||||
import { useCurrentFrame, useVideoConfig, useDelayRender } from "remotion";
|
||||
|
||||
const animationDuration = 20;
|
||||
const cameraAltitude = 4000;
|
||||
```
|
||||
|
||||
```tsx
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const { delayRender, continueRender } = useDelayRender();
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
const handle = delayRender("Moving point...");
|
||||
|
||||
const routeDistance = turf.length(turf.lineString(lineCoordinates));
|
||||
|
||||
const progress = interpolate(
|
||||
frame / fps,
|
||||
[0.00001, animationDuration],
|
||||
[0, 1],
|
||||
{
|
||||
easing: Easing.inOut(Easing.sin),
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
},
|
||||
);
|
||||
|
||||
const camera = map.getFreeCameraOptions();
|
||||
|
||||
const alongRoute = turf.along(
|
||||
turf.lineString(lineCoordinates),
|
||||
routeDistance * progress,
|
||||
).geometry.coordinates;
|
||||
|
||||
camera.lookAtPoint({
|
||||
lng: alongRoute[0],
|
||||
lat: alongRoute[1],
|
||||
});
|
||||
|
||||
map.setFreeCameraOptions(camera);
|
||||
map.once("idle", () => continueRender(handle));
|
||||
}, [lineCoordinates, fps, frame, handle, map]);
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
IMPORTANT: Keep the camera by default so north is up.
|
||||
IMPORTANT: For multi-step animations, set all properties at all stages (zoom, position, line progress) to prevent jumps. Override initial values.
|
||||
|
||||
- The progress is clamped to a minimum value to avoid the line being empty, which can lead to turf errors
|
||||
- See [Timing](./timing.md) for more options for timing.
|
||||
- Consider the dimensions of the composition and make the lines thick enough and the label font size large enough to be legible for when the composition is scaled down.
|
||||
|
||||
## Animating lines
|
||||
|
||||
### Straight lines (linear interpolation)
|
||||
|
||||
To animate a line that appears straight on the map, use linear interpolation between coordinates. Do NOT use turf's `lineSliceAlong` or `along` functions, as they use geodesic (great circle) calculations which appear curved on a Mercator projection.
|
||||
|
||||
```tsx
|
||||
const frame = useCurrentFrame();
|
||||
const { durationInFrames } = useVideoConfig();
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const animationHandle = delayRender("Animating line...");
|
||||
|
||||
const progress = interpolate(frame, [0, durationInFrames - 1], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.inOut(Easing.cubic),
|
||||
});
|
||||
|
||||
// Linear interpolation for a straight line on the map
|
||||
const start = lineCoordinates[0];
|
||||
const end = lineCoordinates[1];
|
||||
const currentLng = start[0] + (end[0] - start[0]) * progress;
|
||||
const currentLat = start[1] + (end[1] - start[1]) * progress;
|
||||
|
||||
const lineData: GeoJSON.Feature<GeoJSON.LineString> = {
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [start, [currentLng, currentLat]],
|
||||
},
|
||||
};
|
||||
|
||||
const source = map.getSource("trace") as mapboxgl.GeoJSONSource;
|
||||
if (source) {
|
||||
source.setData(lineData);
|
||||
}
|
||||
|
||||
map.once("idle", () => continueRender(animationHandle));
|
||||
}, [frame, map, durationInFrames]);
|
||||
```
|
||||
|
||||
### Curved lines (geodesic/great circle)
|
||||
|
||||
To animate a line that follows the geodesic (great circle) path between two points, use turf's `lineSliceAlong`. This is useful for showing flight paths or the actual shortest distance on Earth.
|
||||
|
||||
```tsx
|
||||
import * as turf from "@turf/turf";
|
||||
|
||||
const routeLine = turf.lineString(lineCoordinates);
|
||||
const routeDistance = turf.length(routeLine);
|
||||
|
||||
const currentDistance = Math.max(0.001, routeDistance * progress);
|
||||
const slicedLine = turf.lineSliceAlong(routeLine, 0, currentDistance);
|
||||
|
||||
const source = map.getSource("route") as mapboxgl.GeoJSONSource;
|
||||
if (source) {
|
||||
source.setData(slicedLine);
|
||||
}
|
||||
```
|
||||
|
||||
## Markers
|
||||
|
||||
Add labels, and markers where appropriate.
|
||||
|
||||
```tsx
|
||||
_map.addSource("markers", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: { name: "Point 1" },
|
||||
geometry: { type: "Point", coordinates: [-118.2437, 34.0522] },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
_map.addLayer({
|
||||
id: "city-markers",
|
||||
type: "circle",
|
||||
source: "markers",
|
||||
paint: {
|
||||
"circle-radius": 40,
|
||||
"circle-color": "#FF4444",
|
||||
"circle-stroke-width": 4,
|
||||
"circle-stroke-color": "#FFFFFF",
|
||||
},
|
||||
});
|
||||
|
||||
_map.addLayer({
|
||||
id: "labels",
|
||||
type: "symbol",
|
||||
source: "markers",
|
||||
layout: {
|
||||
"text-field": ["get", "name"],
|
||||
"text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"],
|
||||
"text-size": 50,
|
||||
"text-offset": [0, 0.5],
|
||||
"text-anchor": "top",
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#FFFFFF",
|
||||
"text-halo-color": "#000000",
|
||||
"text-halo-width": 2,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Make sure they are big enough. Check the composition dimensions and scale the labels accordingly.
|
||||
For a composition size of 1920x1080, the label font size should be at least 40px.
|
||||
|
||||
IMPORTANT: Keep the `text-offset` small enough so it is close to the marker. Consider the marker circle radius. For a circle radius of 40, this is a good offset:
|
||||
|
||||
```tsx
|
||||
"text-offset": [0, 0.5],
|
||||
```
|
||||
|
||||
## 3D buildings
|
||||
|
||||
To enable 3D buildings, use the following code:
|
||||
|
||||
```tsx
|
||||
_map.setConfigProperty("basemap", "show3dObjects", true);
|
||||
_map.setConfigProperty("basemap", "show3dLandmarks", true);
|
||||
_map.setConfigProperty("basemap", "show3dBuildings", true);
|
||||
```
|
||||
|
||||
## Rendering
|
||||
|
||||
When rendering a map animation, make sure to render with the following flags:
|
||||
|
||||
```
|
||||
npx remotion render --gl=angle --concurrency=1
|
||||
```
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
name: measuring-dom-nodes
|
||||
description: Measuring DOM element dimensions in Remotion
|
||||
metadata:
|
||||
tags: measure, layout, dimensions, getBoundingClientRect, scale
|
||||
---
|
||||
|
||||
# Measuring DOM nodes in Remotion
|
||||
|
||||
Remotion applies a `scale()` transform to the video container, which affects values from `getBoundingClientRect()`. Use `useCurrentScale()` to get correct measurements.
|
||||
|
||||
## Measuring element dimensions
|
||||
|
||||
```tsx
|
||||
import { useCurrentScale } from "remotion";
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const scale = useCurrentScale();
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
setDimensions({
|
||||
width: rect.width / scale,
|
||||
height: rect.height / scale,
|
||||
});
|
||||
}, [scale]);
|
||||
|
||||
return <div ref={ref}>Content to measure</div>;
|
||||
};
|
||||
```
|
||||
@@ -1,140 +0,0 @@
|
||||
---
|
||||
name: measuring-text
|
||||
description: Measuring text dimensions, fitting text to containers, and checking overflow
|
||||
metadata:
|
||||
tags: measure, text, layout, dimensions, fitText, fillTextBox
|
||||
---
|
||||
|
||||
# Measuring text in Remotion
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install @remotion/layout-utils if it is not already installed:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/layout-utils
|
||||
```
|
||||
|
||||
## Measuring text dimensions
|
||||
|
||||
Use `measureText()` to calculate the width and height of text:
|
||||
|
||||
```tsx
|
||||
import { measureText } from "@remotion/layout-utils";
|
||||
|
||||
const { width, height } = measureText({
|
||||
text: "Hello World",
|
||||
fontFamily: "Arial",
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
});
|
||||
```
|
||||
|
||||
Results are cached - duplicate calls return the cached result.
|
||||
|
||||
## Fitting text to a width
|
||||
|
||||
Use `fitText()` to find the optimal font size for a container:
|
||||
|
||||
```tsx
|
||||
import { fitText } from "@remotion/layout-utils";
|
||||
|
||||
const { fontSize } = fitText({
|
||||
text: "Hello World",
|
||||
withinWidth: 600,
|
||||
fontFamily: "Inter",
|
||||
fontWeight: "bold",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontSize: Math.min(fontSize, 80), // Cap at 80px
|
||||
fontFamily: "Inter",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
Hello World
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## Checking text overflow
|
||||
|
||||
Use `fillTextBox()` to check if text exceeds a box:
|
||||
|
||||
```tsx
|
||||
import { fillTextBox } from "@remotion/layout-utils";
|
||||
|
||||
const box = fillTextBox({ maxBoxWidth: 400, maxLines: 3 });
|
||||
|
||||
const words = ["Hello", "World", "This", "is", "a", "test"];
|
||||
for (const word of words) {
|
||||
const { exceedsBox } = box.add({
|
||||
text: word + " ",
|
||||
fontFamily: "Arial",
|
||||
fontSize: 24,
|
||||
});
|
||||
if (exceedsBox) {
|
||||
// Text would overflow, handle accordingly
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best practices
|
||||
|
||||
**Load fonts first:** Only call measurement functions after fonts are loaded.
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Inter";
|
||||
|
||||
const { fontFamily, waitUntilDone } = loadFont("normal", {
|
||||
weights: ["400"],
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
waitUntilDone().then(() => {
|
||||
// Now safe to measure
|
||||
const { width } = measureText({
|
||||
text: "Hello",
|
||||
fontFamily,
|
||||
fontSize: 32,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Use validateFontIsLoaded:** Catch font loading issues early:
|
||||
|
||||
```tsx
|
||||
measureText({
|
||||
text: "Hello",
|
||||
fontFamily: "MyCustomFont",
|
||||
fontSize: 32,
|
||||
validateFontIsLoaded: true, // Throws if font not loaded
|
||||
});
|
||||
```
|
||||
|
||||
**Match font properties:** Use the same properties for measurement and rendering:
|
||||
|
||||
```tsx
|
||||
const fontStyle = {
|
||||
fontFamily: "Inter",
|
||||
fontSize: 32,
|
||||
fontWeight: "bold" as const,
|
||||
letterSpacing: "0.5px",
|
||||
};
|
||||
|
||||
const { width } = measureText({
|
||||
text: "Hello",
|
||||
...fontStyle,
|
||||
});
|
||||
|
||||
return <div style={fontStyle}>Hello</div>;
|
||||
```
|
||||
|
||||
**Avoid padding and border:** Use `outline` instead of `border` to prevent layout differences:
|
||||
|
||||
```tsx
|
||||
<div style={{ outline: "2px solid red" }}>Text</div>
|
||||
```
|
||||
@@ -1,109 +0,0 @@
|
||||
---
|
||||
name: parameters
|
||||
description: Make a video parametrizable by adding a Zod schema
|
||||
metadata:
|
||||
tags: parameters, zod, schema
|
||||
---
|
||||
|
||||
To make a video parametrizable, a Zod schema can be added to a composition.
|
||||
|
||||
First, `zod` must be installed .
|
||||
|
||||
Search the project for lockfiles and run the correct command depending on the package manager:
|
||||
|
||||
If `package-lock.json` is found, use the following command:
|
||||
|
||||
```bash
|
||||
npm i zod
|
||||
```
|
||||
|
||||
If `bun.lockb` is found, use the following command:
|
||||
|
||||
```bash
|
||||
bun i zod
|
||||
```
|
||||
|
||||
If `yarn.lock` is found, use the following command:
|
||||
|
||||
```bash
|
||||
yarn add zod
|
||||
```
|
||||
|
||||
If `pnpm-lock.yaml` is found, use the following command:
|
||||
|
||||
```bash
|
||||
pnpm i zod
|
||||
```
|
||||
|
||||
Then, a Zod schema can be defined alongside the component:
|
||||
|
||||
```tsx title="src/MyComposition.tsx"
|
||||
import { z } from "zod";
|
||||
|
||||
export const MyCompositionSchema = z.object({
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
const MyComponent: React.FC<z.infer<typeof MyCompositionSchema>> = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>{props.title}</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
In the root file, the schema can be passed to the composition:
|
||||
|
||||
```tsx title="src/Root.tsx"
|
||||
import { Composition } from "remotion";
|
||||
import { MycComponent, MyCompositionSchema } from "./MyComposition";
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return (
|
||||
<Composition
|
||||
id="MyComposition"
|
||||
component={MyComponent}
|
||||
durationInFrames={100}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1080}
|
||||
defaultProps={{ title: "Hello World" }}
|
||||
schema={MyCompositionSchema}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Now, the user can edit the parameter visually in the sidebar.
|
||||
|
||||
All schemas that are supported by Zod are supported by Remotion.
|
||||
|
||||
Remotion requires that the top-level type is a z.object(), because the collection of props of a React component is always an object.
|
||||
|
||||
## Color picker
|
||||
|
||||
For adding a color picker, use `zColor()` from `@remotion/zod-types`.
|
||||
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/zod-types # If project uses npm
|
||||
bunx remotion add @remotion/zod-types # If project uses bun
|
||||
yarn remotion add @remotion/zod-types # If project uses yarn
|
||||
pnpm exec remotion add @remotion/zod-types # If project uses pnpm
|
||||
```
|
||||
|
||||
Then import `zColor` from `@remotion/zod-types`:
|
||||
|
||||
```tsx
|
||||
import { zColor } from "@remotion/zod-types";
|
||||
```
|
||||
|
||||
Then use it in the schema:
|
||||
|
||||
```tsx
|
||||
export const MyCompositionSchema = z.object({
|
||||
color: zColor(),
|
||||
});
|
||||
```
|
||||
@@ -1,118 +0,0 @@
|
||||
---
|
||||
name: sequencing
|
||||
description: Sequencing patterns for Remotion - delay, trim, limit duration of items
|
||||
metadata:
|
||||
tags: sequence, series, timing, delay, trim
|
||||
---
|
||||
|
||||
Use `<Sequence>` to delay when an element appears in the timeline.
|
||||
|
||||
```tsx
|
||||
import { Sequence } from "remotion";
|
||||
|
||||
const {fps} = useVideoConfig();
|
||||
|
||||
<Sequence from={1 * fps} durationInFrames={2 * fps} premountFor={1 * fps}>
|
||||
<Title />
|
||||
</Sequence>
|
||||
<Sequence from={2 * fps} durationInFrames={2 * fps} premountFor={1 * fps}>
|
||||
<Subtitle />
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
This will by default wrap the component in an absolute fill element.
|
||||
If the items should not be wrapped, use the `layout` prop:
|
||||
|
||||
```tsx
|
||||
<Sequence layout="none">
|
||||
<Title />
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
## Premounting
|
||||
|
||||
This loads the component in the timeline before it is actually played.
|
||||
Always premount any `<Sequence>`!
|
||||
|
||||
```tsx
|
||||
<Sequence premountFor={1 * fps}>
|
||||
<Title />
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
## Series
|
||||
|
||||
Use `<Series>` when elements should play one after another without overlap.
|
||||
|
||||
```tsx
|
||||
import { Series } from "remotion";
|
||||
|
||||
<Series>
|
||||
<Series.Sequence durationInFrames={45}>
|
||||
<Intro />
|
||||
</Series.Sequence>
|
||||
<Series.Sequence durationInFrames={60}>
|
||||
<MainContent />
|
||||
</Series.Sequence>
|
||||
<Series.Sequence durationInFrames={30}>
|
||||
<Outro />
|
||||
</Series.Sequence>
|
||||
</Series>;
|
||||
```
|
||||
|
||||
Same as with `<Sequence>`, the items will be wrapped in an absolute fill element by default when using `<Series.Sequence>`, unless the `layout` prop is set to `none`.
|
||||
|
||||
### Series with overlaps
|
||||
|
||||
Use negative offset for overlapping sequences:
|
||||
|
||||
```tsx
|
||||
<Series>
|
||||
<Series.Sequence durationInFrames={60}>
|
||||
<SceneA />
|
||||
</Series.Sequence>
|
||||
<Series.Sequence offset={-15} durationInFrames={60}>
|
||||
{/* Starts 15 frames before SceneA ends */}
|
||||
<SceneB />
|
||||
</Series.Sequence>
|
||||
</Series>
|
||||
```
|
||||
|
||||
## Frame References Inside Sequences
|
||||
|
||||
Inside a Sequence, `useCurrentFrame()` returns the local frame (starting from 0):
|
||||
|
||||
```tsx
|
||||
<Sequence from={60} durationInFrames={30}>
|
||||
<MyComponent />
|
||||
{/* Inside MyComponent, useCurrentFrame() returns 0-29, not 60-89 */}
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
## Nested Sequences
|
||||
|
||||
Sequences can be nested for complex timing:
|
||||
|
||||
```tsx
|
||||
<Sequence from={0} durationInFrames={120}>
|
||||
<Background />
|
||||
<Sequence from={15} durationInFrames={90} layout="none">
|
||||
<Title />
|
||||
</Sequence>
|
||||
<Sequence from={45} durationInFrames={60} layout="none">
|
||||
<Subtitle />
|
||||
</Sequence>
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
## Nesting compositions within another
|
||||
|
||||
To add a composition within another composition, you can use the `<Sequence>` component with a `width` and `height` prop to specify the size of the composition.
|
||||
|
||||
```tsx
|
||||
<AbsoluteFill>
|
||||
<Sequence width={COMPOSITION_WIDTH} height={COMPOSITION_HEIGHT}>
|
||||
<CompositionComponent />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
```
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
name: sfx
|
||||
description: Including sound effects
|
||||
metadata:
|
||||
tags: sfx, sound, effect, audio
|
||||
---
|
||||
|
||||
To include a sound effect, use the `<Audio>` tag:
|
||||
|
||||
```tsx
|
||||
import { Audio } from "@remotion/sfx";
|
||||
|
||||
<Audio src={"https://remotion.media/whoosh.wav"} />;
|
||||
```
|
||||
|
||||
The following sound effects are available:
|
||||
|
||||
- `https://remotion.media/whoosh.wav`
|
||||
- `https://remotion.media/whip.wav`
|
||||
- `https://remotion.media/page-turn.wav`
|
||||
- `https://remotion.media/switch.wav`
|
||||
- `https://remotion.media/mouse-click.wav`
|
||||
- `https://remotion.media/shutter-modern.wav`
|
||||
- `https://remotion.media/shutter-old.wav`
|
||||
|
||||
For more sound effects, search the internet. A good resource is https://github.com/kapishdima/soundcn/tree/main/assets.
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
name: subtitles
|
||||
description: subtitles and caption rules
|
||||
metadata:
|
||||
tags: subtitles, captions, remotion, json
|
||||
---
|
||||
|
||||
All captions must be processed in JSON. The captions must use the `Caption` type which is the following:
|
||||
|
||||
```ts
|
||||
import type { Caption } from "@remotion/captions";
|
||||
```
|
||||
|
||||
This is the definition:
|
||||
|
||||
```ts
|
||||
type Caption = {
|
||||
text: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
timestampMs: number | null;
|
||||
confidence: number | null;
|
||||
};
|
||||
```
|
||||
|
||||
## Generating captions
|
||||
|
||||
To transcribe video and audio files to generate captions, load the [./transcribe-captions.md](./transcribe-captions.md) file for more instructions.
|
||||
|
||||
## Displaying captions
|
||||
|
||||
To display captions in your video, load the [./display-captions.md](./display-captions.md) file for more instructions.
|
||||
|
||||
## Importing captions
|
||||
|
||||
To import captions from a .srt file, load the [./import-srt-captions.md](./import-srt-captions.md) file for more instructions.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: tailwind
|
||||
description: Using TailwindCSS in Remotion.
|
||||
metadata:
|
||||
---
|
||||
|
||||
You can and should use TailwindCSS in Remotion, if TailwindCSS is installed in the project.
|
||||
|
||||
Don't use `transition-*` or `animate-*` classes - always animate using the `useCurrentFrame()` hook.
|
||||
|
||||
Tailwind must be installed and enabled first in a Remotion project - fetch https://www.remotion.dev/docs/tailwind using WebFetch for instructions.
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: text-animations
|
||||
description: Typography and text animation patterns for Remotion.
|
||||
metadata:
|
||||
tags: typography, text, typewriter, highlighter ken
|
||||
---
|
||||
|
||||
## Text animations
|
||||
|
||||
Based on `useCurrentFrame()`, reduce the string character by character to create a typewriter effect.
|
||||
|
||||
## Typewriter Effect
|
||||
|
||||
See [Typewriter](assets/text-animations-typewriter.tsx) for an advanced example with a blinking cursor and a pause after the first sentence.
|
||||
|
||||
Always use string slicing for typewriter effects. Never use per-character opacity.
|
||||
|
||||
## Word Highlighting
|
||||
|
||||
See [Word Highlight](assets/text-animations-word-highlight.tsx) for an example for how a word highlight is animated, like with a highlighter pen.
|
||||
@@ -1,179 +0,0 @@
|
||||
---
|
||||
name: timing
|
||||
description: Interpolation curves in Remotion - linear, easing, spring animations
|
||||
metadata:
|
||||
tags: spring, bounce, easing, interpolation
|
||||
---
|
||||
|
||||
A simple linear interpolation is done using the `interpolate` function.
|
||||
|
||||
```ts title="Going from 0 to 1 over 100 frames"
|
||||
import { interpolate } from "remotion";
|
||||
|
||||
const opacity = interpolate(frame, [0, 100], [0, 1]);
|
||||
```
|
||||
|
||||
By default, the values are not clamped, so the value can go outside the range [0, 1].
|
||||
Here is how they can be clamped:
|
||||
|
||||
```ts title="Going from 0 to 1 over 100 frames with extrapolation"
|
||||
const opacity = interpolate(frame, [0, 100], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
extrapolateLeft: "clamp",
|
||||
});
|
||||
```
|
||||
|
||||
## Spring animations
|
||||
|
||||
Spring animations have a more natural motion.
|
||||
They go from 0 to 1 over time.
|
||||
|
||||
```ts title="Spring animation from 0 to 1 over 100 frames"
|
||||
import { spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const scale = spring({
|
||||
frame,
|
||||
fps,
|
||||
});
|
||||
```
|
||||
|
||||
### Physical properties
|
||||
|
||||
The default configuration is: `mass: 1, damping: 10, stiffness: 100`.
|
||||
This leads to the animation having a bit of bounce before it settles.
|
||||
|
||||
The config can be overwritten like this:
|
||||
|
||||
```ts
|
||||
const scale = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 200 },
|
||||
});
|
||||
```
|
||||
|
||||
The recommended configuration for a natural motion without a bounce is: `{ damping: 200 }`.
|
||||
|
||||
Here are some common configurations:
|
||||
|
||||
```tsx
|
||||
const smooth = { damping: 200 }; // Smooth, no bounce (subtle reveals)
|
||||
const snappy = { damping: 20, stiffness: 200 }; // Snappy, minimal bounce (UI elements)
|
||||
const bouncy = { damping: 8 }; // Bouncy entrance (playful animations)
|
||||
const heavy = { damping: 15, stiffness: 80, mass: 2 }; // Heavy, slow, small bounce
|
||||
```
|
||||
|
||||
### Delay
|
||||
|
||||
The animation starts immediately by default.
|
||||
Use the `delay` parameter to delay the animation by a number of frames.
|
||||
|
||||
```tsx
|
||||
const entrance = spring({
|
||||
frame: frame - ENTRANCE_DELAY,
|
||||
fps,
|
||||
delay: 20,
|
||||
});
|
||||
```
|
||||
|
||||
### Duration
|
||||
|
||||
A `spring()` has a natural duration based on the physical properties.
|
||||
To stretch the animation to a specific duration, use the `durationInFrames` parameter.
|
||||
|
||||
```tsx
|
||||
const spring = spring({
|
||||
frame,
|
||||
fps,
|
||||
durationInFrames: 40,
|
||||
});
|
||||
```
|
||||
|
||||
### Combining spring() with interpolate()
|
||||
|
||||
Map spring output (0-1) to custom ranges:
|
||||
|
||||
```tsx
|
||||
const springProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
});
|
||||
|
||||
// Map to rotation
|
||||
const rotation = interpolate(springProgress, [0, 1], [0, 360]);
|
||||
|
||||
<div style={{ rotate: rotation + "deg" }} />;
|
||||
```
|
||||
|
||||
### Adding springs
|
||||
|
||||
Springs return just numbers, so math can be performed:
|
||||
|
||||
```tsx
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
|
||||
const inAnimation = spring({
|
||||
frame,
|
||||
fps,
|
||||
});
|
||||
const outAnimation = spring({
|
||||
frame,
|
||||
fps,
|
||||
durationInFrames: 1 * fps,
|
||||
delay: durationInFrames - 1 * fps,
|
||||
});
|
||||
|
||||
const scale = inAnimation - outAnimation;
|
||||
```
|
||||
|
||||
## Easing
|
||||
|
||||
Easing can be added to the `interpolate` function:
|
||||
|
||||
```ts
|
||||
import { interpolate, Easing } from "remotion";
|
||||
|
||||
const value1 = interpolate(frame, [0, 100], [0, 1], {
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
```
|
||||
|
||||
The default easing is `Easing.linear`.
|
||||
There are various other convexities:
|
||||
|
||||
- `Easing.in` for starting slow and accelerating
|
||||
- `Easing.out` for starting fast and slowing down
|
||||
- `Easing.inOut`
|
||||
|
||||
and curves (sorted from most linear to most curved):
|
||||
|
||||
- `Easing.quad`
|
||||
- `Easing.sin`
|
||||
- `Easing.exp`
|
||||
- `Easing.circle`
|
||||
|
||||
Convexities and curves need be combined for an easing function:
|
||||
|
||||
```ts
|
||||
const value1 = interpolate(frame, [0, 100], [0, 1], {
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
```
|
||||
|
||||
Cubic bezier curves are also supported:
|
||||
|
||||
```ts
|
||||
const value1 = interpolate(frame, [0, 100], [0, 1], {
|
||||
easing: Easing.bezier(0.8, 0.22, 0.96, 0.65),
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
```
|
||||
@@ -1,70 +0,0 @@
|
||||
---
|
||||
name: transcribe-captions
|
||||
description: Transcribing audio to generate captions in Remotion
|
||||
metadata:
|
||||
tags: captions, transcribe, whisper, audio, speech-to-text
|
||||
---
|
||||
|
||||
# Transcribing audio
|
||||
|
||||
To transcribe audio to generate captions in Remotion, you can use the [`transcribe()`](https://www.remotion.dev/docs/install-whisper-cpp/transcribe) function from the [`@remotion/install-whisper-cpp`](https://www.remotion.dev/docs/install-whisper-cpp) package.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the @remotion/install-whisper-cpp package needs to be installed.
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/install-whisper-cpp
|
||||
```
|
||||
|
||||
## Transcribing
|
||||
|
||||
Make a Node.js script to download Whisper.cpp and a model, and transcribe the audio.
|
||||
|
||||
```ts
|
||||
import path from "path";
|
||||
import {
|
||||
downloadWhisperModel,
|
||||
installWhisperCpp,
|
||||
transcribe,
|
||||
toCaptions,
|
||||
} from "@remotion/install-whisper-cpp";
|
||||
import fs from "fs";
|
||||
|
||||
const to = path.join(process.cwd(), "whisper.cpp");
|
||||
|
||||
await installWhisperCpp({
|
||||
to,
|
||||
version: "1.5.5",
|
||||
});
|
||||
|
||||
await downloadWhisperModel({
|
||||
model: "medium.en",
|
||||
folder: to,
|
||||
});
|
||||
|
||||
// Convert the audio to a 16KHz wav file first if needed:
|
||||
// import {execSync} from 'child_process';
|
||||
// execSync('ffmpeg -i /path/to/audio.mp4 -ar 16000 /path/to/audio.wav -y');
|
||||
|
||||
const whisperCppOutput = await transcribe({
|
||||
model: "medium.en",
|
||||
whisperPath: to,
|
||||
whisperCppVersion: "1.5.5",
|
||||
inputPath: "/path/to/audio123.wav",
|
||||
tokenLevelTimestamps: true,
|
||||
});
|
||||
|
||||
// Optional: Apply our recommended postprocessing
|
||||
const { captions } = toCaptions({
|
||||
whisperCppOutput,
|
||||
});
|
||||
|
||||
// Write it to the public/ folder so it can be fetched from Remotion
|
||||
fs.writeFileSync("captions123.json", JSON.stringify(captions, null, 2));
|
||||
```
|
||||
|
||||
Transcribe each clip individually and create multiple JSON files.
|
||||
|
||||
See [Displaying captions](display-captions.md) for how to display the captions in Remotion.
|
||||
@@ -1,197 +0,0 @@
|
||||
---
|
||||
name: transitions
|
||||
description: Scene transitions and overlays for Remotion using TransitionSeries.
|
||||
metadata:
|
||||
tags: transitions, overlays, fade, slide, wipe, scenes
|
||||
---
|
||||
|
||||
## TransitionSeries
|
||||
|
||||
`<TransitionSeries>` arranges scenes and supports two ways to enhance the cut point between them:
|
||||
|
||||
- **Transitions** (`<TransitionSeries.Transition>`) — crossfade, slide, wipe, etc. between two scenes. Shortens the timeline because both scenes play simultaneously during the transition.
|
||||
- **Overlays** (`<TransitionSeries.Overlay>`) — render an effect (e.g. a light leak) on top of the cut point without shortening the timeline.
|
||||
|
||||
Children are absolutely positioned.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/transitions
|
||||
```
|
||||
|
||||
## Transition example
|
||||
|
||||
```tsx
|
||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneA />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: 15 })}
|
||||
/>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneB />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>;
|
||||
```
|
||||
|
||||
## Overlay example
|
||||
|
||||
Any React component can be used as an overlay. For a ready-made effect, see the **light-leaks** rule.
|
||||
|
||||
```tsx
|
||||
import { TransitionSeries } from "@remotion/transitions";
|
||||
import { LightLeak } from "@remotion/light-leaks";
|
||||
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneA />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Overlay durationInFrames={20}>
|
||||
<LightLeak />
|
||||
</TransitionSeries.Overlay>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneB />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>;
|
||||
```
|
||||
|
||||
## Mixing transitions and overlays
|
||||
|
||||
Transitions and overlays can coexist in the same `<TransitionSeries>`, but an overlay cannot be adjacent to a transition or another overlay.
|
||||
|
||||
```tsx
|
||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
import { LightLeak } from "@remotion/light-leaks";
|
||||
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneA />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Overlay durationInFrames={30}>
|
||||
<LightLeak />
|
||||
</TransitionSeries.Overlay>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneB />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: 15 })}
|
||||
/>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneC />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>;
|
||||
```
|
||||
|
||||
## Transition props
|
||||
|
||||
`<TransitionSeries.Transition>` requires:
|
||||
|
||||
- `presentation` — the visual effect (e.g. `fade()`, `slide()`, `wipe()`).
|
||||
- `timing` — controls speed and easing (e.g. `linearTiming()`, `springTiming()`).
|
||||
|
||||
## Overlay props
|
||||
|
||||
`<TransitionSeries.Overlay>` accepts:
|
||||
|
||||
- `durationInFrames` — how long the overlay is visible (positive integer).
|
||||
- `offset?` — shifts the overlay relative to the cut point center. Positive = later, negative = earlier. Default: `0`.
|
||||
|
||||
## Available transition types
|
||||
|
||||
Import transitions from their respective modules:
|
||||
|
||||
```tsx
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
import { slide } from "@remotion/transitions/slide";
|
||||
import { wipe } from "@remotion/transitions/wipe";
|
||||
import { flip } from "@remotion/transitions/flip";
|
||||
import { clockWipe } from "@remotion/transitions/clock-wipe";
|
||||
```
|
||||
|
||||
## Slide transition with direction
|
||||
|
||||
```tsx
|
||||
import { slide } from "@remotion/transitions/slide";
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={slide({ direction: "from-left" })}
|
||||
timing={linearTiming({ durationInFrames: 20 })}
|
||||
/>;
|
||||
```
|
||||
|
||||
Directions: `"from-left"`, `"from-right"`, `"from-top"`, `"from-bottom"`
|
||||
|
||||
## Timing options
|
||||
|
||||
```tsx
|
||||
import { linearTiming, springTiming } from "@remotion/transitions";
|
||||
|
||||
// Linear timing - constant speed
|
||||
linearTiming({ durationInFrames: 20 });
|
||||
|
||||
// Spring timing - organic motion
|
||||
springTiming({ config: { damping: 200 }, durationInFrames: 25 });
|
||||
```
|
||||
|
||||
## Duration calculation
|
||||
|
||||
Transitions overlap adjacent scenes, so the total composition length is **shorter** than the sum of all sequence durations. Overlays do **not** affect the total duration.
|
||||
|
||||
For example, with two 60-frame sequences and a 15-frame transition:
|
||||
|
||||
- Without transitions: `60 + 60 = 120` frames
|
||||
- With transition: `60 + 60 - 15 = 105` frames
|
||||
|
||||
Adding an overlay between two other sequences does not change the total.
|
||||
|
||||
### Getting the duration of a transition
|
||||
|
||||
Use the `getDurationInFrames()` method on the timing object:
|
||||
|
||||
```tsx
|
||||
import { linearTiming, springTiming } from "@remotion/transitions";
|
||||
|
||||
const linearDuration = linearTiming({
|
||||
durationInFrames: 20,
|
||||
}).getDurationInFrames({ fps: 30 });
|
||||
// Returns 20
|
||||
|
||||
const springDuration = springTiming({
|
||||
config: { damping: 200 },
|
||||
}).getDurationInFrames({ fps: 30 });
|
||||
// Returns calculated duration based on spring physics
|
||||
```
|
||||
|
||||
For `springTiming` without an explicit `durationInFrames`, the duration depends on `fps` because it calculates when the spring animation settles.
|
||||
|
||||
### Calculating total composition duration
|
||||
|
||||
```tsx
|
||||
import { linearTiming } from "@remotion/transitions";
|
||||
|
||||
const scene1Duration = 60;
|
||||
const scene2Duration = 60;
|
||||
const scene3Duration = 60;
|
||||
|
||||
const timing1 = linearTiming({ durationInFrames: 15 });
|
||||
const timing2 = linearTiming({ durationInFrames: 20 });
|
||||
|
||||
const transition1Duration = timing1.getDurationInFrames({ fps: 30 });
|
||||
const transition2Duration = timing2.getDurationInFrames({ fps: 30 });
|
||||
|
||||
const totalDuration =
|
||||
scene1Duration +
|
||||
scene2Duration +
|
||||
scene3Duration -
|
||||
transition1Duration -
|
||||
transition2Duration;
|
||||
// 60 + 60 + 60 - 15 - 20 = 145 frames
|
||||
```
|
||||
@@ -1,106 +0,0 @@
|
||||
---
|
||||
name: transparent-videos
|
||||
description: Rendering transparent videos in Remotion
|
||||
metadata:
|
||||
tags: transparent, alpha, codec, vp9, prores, webm
|
||||
---
|
||||
|
||||
# Rendering Transparent Videos
|
||||
|
||||
Remotion can render transparent videos in two ways: as a ProRes video or as a WebM video.
|
||||
|
||||
## Transparent ProRes
|
||||
|
||||
Ideal for when importing into video editing software.
|
||||
|
||||
**CLI:**
|
||||
|
||||
```bash
|
||||
npx remotion render --image-format=png --pixel-format=yuva444p10le --codec=prores --prores-profile=4444 MyComp out.mov
|
||||
```
|
||||
|
||||
**Default in Studio** (restart Studio after changing):
|
||||
|
||||
```ts
|
||||
// remotion.config.ts
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
Config.setVideoImageFormat("png");
|
||||
Config.setPixelFormat("yuva444p10le");
|
||||
Config.setCodec("prores");
|
||||
Config.setProResProfile("4444");
|
||||
```
|
||||
|
||||
**Setting it as the default export settings for a composition** (using `calculateMetadata`):
|
||||
|
||||
```tsx
|
||||
import { CalculateMetadataFunction } from "remotion";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
return {
|
||||
defaultCodec: "prores",
|
||||
defaultVideoImageFormat: "png",
|
||||
defaultPixelFormat: "yuva444p10le",
|
||||
defaultProResProfile: "4444",
|
||||
};
|
||||
};
|
||||
|
||||
<Composition
|
||||
id="my-video"
|
||||
component={MyVideo}
|
||||
durationInFrames={150}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
calculateMetadata={calculateMetadata}
|
||||
/>;
|
||||
```
|
||||
|
||||
## Transparent WebM (VP9)
|
||||
|
||||
Ideal for when playing in a browser.
|
||||
|
||||
**CLI:**
|
||||
|
||||
```bash
|
||||
npx remotion render --image-format=png --pixel-format=yuva420p --codec=vp9 MyComp out.webm
|
||||
```
|
||||
|
||||
**Default in Studio** (restart Studio after changing):
|
||||
|
||||
```ts
|
||||
// remotion.config.ts
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
Config.setVideoImageFormat("png");
|
||||
Config.setPixelFormat("yuva420p");
|
||||
Config.setCodec("vp9");
|
||||
```
|
||||
|
||||
**Setting it as the default export settings for a composition** (using `calculateMetadata`):
|
||||
|
||||
```tsx
|
||||
import { CalculateMetadataFunction } from "remotion";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
return {
|
||||
defaultCodec: "vp8",
|
||||
defaultVideoImageFormat: "png",
|
||||
defaultPixelFormat: "yuva420p",
|
||||
};
|
||||
};
|
||||
|
||||
<Composition
|
||||
id="my-video"
|
||||
component={MyVideo}
|
||||
durationInFrames={150}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
calculateMetadata={calculateMetadata}
|
||||
/>;
|
||||
```
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
name: trimming
|
||||
description: Trimming patterns for Remotion - cut the beginning or end of animations
|
||||
metadata:
|
||||
tags: sequence, trim, clip, cut, offset
|
||||
---
|
||||
|
||||
Use `<Sequence>` with a negative `from` value to trim the start of an animation.
|
||||
|
||||
## Trim the Beginning
|
||||
|
||||
A negative `from` value shifts time backwards, making the animation start partway through:
|
||||
|
||||
```tsx
|
||||
import { Sequence, useVideoConfig } from "remotion";
|
||||
|
||||
const fps = useVideoConfig();
|
||||
|
||||
<Sequence from={-0.5 * fps}>
|
||||
<MyAnimation />
|
||||
</Sequence>;
|
||||
```
|
||||
|
||||
The animation appears 15 frames into its progress - the first 15 frames are trimmed off.
|
||||
Inside `<MyAnimation>`, `useCurrentFrame()` starts at 15 instead of 0.
|
||||
|
||||
## Trim the End
|
||||
|
||||
Use `durationInFrames` to unmount content after a specified duration:
|
||||
|
||||
```tsx
|
||||
<Sequence durationInFrames={1.5 * fps}>
|
||||
<MyAnimation />
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
The animation plays for 45 frames, then the component unmounts.
|
||||
|
||||
## Trim and Delay
|
||||
|
||||
Nest sequences to both trim the beginning and delay when it appears:
|
||||
|
||||
```tsx
|
||||
<Sequence from={30}>
|
||||
<Sequence from={-15}>
|
||||
<MyAnimation />
|
||||
</Sequence>
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
The inner sequence trims 15 frames from the start, and the outer sequence delays the result by 30 frames.
|
||||
@@ -1,171 +0,0 @@
|
||||
---
|
||||
name: videos
|
||||
description: Embedding videos in Remotion - trimming, volume, speed, looping, pitch
|
||||
metadata:
|
||||
tags: video, media, trim, volume, speed, loop, pitch
|
||||
---
|
||||
|
||||
# Using videos in Remotion
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the @remotion/media package needs to be installed.
|
||||
If it is not, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/media # If project uses npm
|
||||
bunx remotion add @remotion/media # If project uses bun
|
||||
yarn remotion add @remotion/media # If project uses yarn
|
||||
pnpm exec remotion add @remotion/media # If project uses pnpm
|
||||
```
|
||||
|
||||
Use `<Video>` from `@remotion/media` to embed videos into your composition.
|
||||
|
||||
```tsx
|
||||
import { Video } from "@remotion/media";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <Video src={staticFile("video.mp4")} />;
|
||||
};
|
||||
```
|
||||
|
||||
Remote URLs are also supported:
|
||||
|
||||
```tsx
|
||||
<Video src="https://remotion.media/video.mp4" />
|
||||
```
|
||||
|
||||
## Trimming
|
||||
|
||||
Use `trimBefore` and `trimAfter` to remove portions of the video. Values are in seconds.
|
||||
|
||||
```tsx
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
trimBefore={2 * fps} // Skip the first 2 seconds
|
||||
trimAfter={10 * fps} // End at the 10 second mark
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
## Delaying
|
||||
|
||||
Wrap the video in a `<Sequence>` to delay when it appears:
|
||||
|
||||
```tsx
|
||||
import { Sequence, staticFile } from "remotion";
|
||||
import { Video } from "@remotion/media";
|
||||
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Sequence from={1 * fps}>
|
||||
<Video src={staticFile("video.mp4")} />
|
||||
</Sequence>
|
||||
);
|
||||
```
|
||||
|
||||
The video will appear after 1 second.
|
||||
|
||||
## Sizing and Position
|
||||
|
||||
Use the `style` prop to control size and position:
|
||||
|
||||
```tsx
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
style={{
|
||||
width: 500,
|
||||
height: 300,
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
left: 50,
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Volume
|
||||
|
||||
Set a static volume (0 to 1):
|
||||
|
||||
```tsx
|
||||
<Video src={staticFile("video.mp4")} volume={0.5} />
|
||||
```
|
||||
|
||||
Or use a callback for dynamic volume based on the current frame:
|
||||
|
||||
```tsx
|
||||
import { interpolate } from "remotion";
|
||||
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
volume={(f) =>
|
||||
interpolate(f, [0, 1 * fps], [0, 1], { extrapolateRight: "clamp" })
|
||||
}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
Use `muted` to silence the video entirely:
|
||||
|
||||
```tsx
|
||||
<Video src={staticFile("video.mp4")} muted />
|
||||
```
|
||||
|
||||
## Speed
|
||||
|
||||
Use `playbackRate` to change the playback speed:
|
||||
|
||||
```tsx
|
||||
<Video src={staticFile("video.mp4")} playbackRate={2} /> {/* 2x speed */}
|
||||
<Video src={staticFile("video.mp4")} playbackRate={0.5} /> {/* Half speed */}
|
||||
```
|
||||
|
||||
Reverse playback is not supported.
|
||||
|
||||
## Looping
|
||||
|
||||
Use `loop` to loop the video indefinitely:
|
||||
|
||||
```tsx
|
||||
<Video src={staticFile("video.mp4")} loop />
|
||||
```
|
||||
|
||||
Use `loopVolumeCurveBehavior` to control how the frame count behaves when looping:
|
||||
|
||||
- `"repeat"`: Frame count resets to 0 each loop (for `volume` callback)
|
||||
- `"extend"`: Frame count continues incrementing
|
||||
|
||||
```tsx
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
loop
|
||||
loopVolumeCurveBehavior="extend"
|
||||
volume={(f) => interpolate(f, [0, 300], [1, 0])} // Fade out over multiple loops
|
||||
/>
|
||||
```
|
||||
|
||||
## Pitch
|
||||
|
||||
Use `toneFrequency` to adjust the pitch without affecting speed. Values range from 0.01 to 2:
|
||||
|
||||
```tsx
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
toneFrequency={1.5} // Higher pitch
|
||||
/>
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
toneFrequency={0.8} // Lower pitch
|
||||
/>
|
||||
```
|
||||
|
||||
Pitch shifting only works during server-side rendering, not in the Remotion Studio preview or in the `<Player />`.
|
||||
@@ -1,99 +0,0 @@
|
||||
---
|
||||
name: voiceover
|
||||
description: Adding AI-generated voiceover to Remotion compositions using ElevenLabs TTS
|
||||
metadata:
|
||||
tags: voiceover, audio, elevenlabs, tts, speech, calculateMetadata, dynamic duration
|
||||
---
|
||||
|
||||
# Adding AI voiceover to a Remotion composition
|
||||
|
||||
Use ElevenLabs TTS to generate speech audio per scene, then use [`calculateMetadata`](./calculate-metadata) to dynamically size the composition to match the audio.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
An **ElevenLabs API key** is required (`ELEVENLABS_API_KEY` environment variable).
|
||||
|
||||
**MUST** ask the user for their ElevenLabs API key if `ELEVENLABS_API_KEY` is not set. **MUST NOT** fall back to other TTS tools.
|
||||
|
||||
Ensure the environment variable is available when running the generation script:
|
||||
|
||||
```bash
|
||||
node --strip-types generate-voiceover.ts
|
||||
```
|
||||
|
||||
## Generating audio with ElevenLabs
|
||||
|
||||
Create a script that reads the config, calls the ElevenLabs API for each scene, and writes MP3 files to the `public/` directory so Remotion can access them via `staticFile()`.
|
||||
|
||||
The core API call for a single scene:
|
||||
|
||||
```ts title="generate-voiceover.ts"
|
||||
const response = await fetch(
|
||||
`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"xi-api-key": process.env.ELEVENLABS_API_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "audio/mpeg",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: "Welcome to the show.",
|
||||
model_id: "eleven_multilingual_v2",
|
||||
voice_settings: {
|
||||
stability: 0.5,
|
||||
similarity_boost: 0.75,
|
||||
style: 0.3,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const audioBuffer = Buffer.from(await response.arrayBuffer());
|
||||
writeFileSync(`public/voiceover/${compositionId}/${scene.id}.mp3`, audioBuffer);
|
||||
```
|
||||
|
||||
## Dynamic composition duration with calculateMetadata
|
||||
|
||||
Use [`calculateMetadata`](./calculate-metadata.md) to measure the [audio durations](./get-audio-duration.md) and set the composition length accordingly.
|
||||
|
||||
```tsx
|
||||
import { CalculateMetadataFunction, staticFile } from "remotion";
|
||||
import { getAudioDuration } from "./get-audio-duration";
|
||||
|
||||
const FPS = 30;
|
||||
|
||||
const SCENE_AUDIO_FILES = [
|
||||
"voiceover/my-comp/scene-01-intro.mp3",
|
||||
"voiceover/my-comp/scene-02-main.mp3",
|
||||
"voiceover/my-comp/scene-03-outro.mp3",
|
||||
];
|
||||
|
||||
export const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
const durations = await Promise.all(
|
||||
SCENE_AUDIO_FILES.map((file) => getAudioDuration(staticFile(file))),
|
||||
);
|
||||
|
||||
const sceneDurations = durations.map((durationInSeconds) => {
|
||||
return durationInSeconds * FPS;
|
||||
});
|
||||
|
||||
return {
|
||||
durationInFrames: Math.ceil(sceneDurations.reduce((sum, d) => sum + d, 0)),
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
The computed `sceneDurations` are passed into the component via a `voiceover` prop so the component knows how long each scene should be.
|
||||
|
||||
If the composition uses [`<TransitionSeries>`](./transitions.md), subtract the overlap from total duration: [./transitions.md#calculating-total-composition-duration](./transitions.md#calculating-total-composition-duration)
|
||||
|
||||
## Rendering audio in the component
|
||||
|
||||
See [audio.md](./audio.md) for more information on how to render audio in the component.
|
||||
|
||||
## Delaying audio start
|
||||
|
||||
See [audio.md#delaying](./audio.md#delaying) for more information on how to delay the audio start.
|
||||
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,36 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: Typecheck & Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
|
||||
# Note: The test suite is intentionally excluded from CI.
|
||||
# Tests spawn real tmux sessions and require a full system environment.
|
||||
# Run tests locally with: npx vitest run test/<file>.test.ts
|
||||
@@ -11,6 +11,9 @@ jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
@@ -20,6 +23,7 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -37,4 +41,4 @@ jobs:
|
||||
commit: "chore: version packages"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# Claude Code local files
|
||||
.agents/
|
||||
skills-lock.json
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
@@ -41,6 +45,14 @@ Thumbs.db
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Generated output
|
||||
out/
|
||||
screenshots-echo-diag/
|
||||
tools/remotion/out/
|
||||
|
||||
# Claude Code plan tracking
|
||||
plan.json
|
||||
|
||||
# Unfinished TUI (local development only)
|
||||
src/tui/
|
||||
.claude/
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
dist/
|
||||
coverage/
|
||||
node_modules/
|
||||
src/web/public/vendor/
|
||||
src/web/public/app.js
|
||||
src/web/public/styles.css
|
||||
src/web/public/mobile.css
|
||||
src/web/public/index.html
|
||||
tools/
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 120,
|
||||
"trailingComma": "es5",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -1,4 +1,60 @@
|
||||
# codeman
|
||||
# aicodeman
|
||||
|
||||
## 0.2.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- System-level performance optimizations (Phase 4): stream parent transcripts instead of full reads, consolidate subagent file watchers from 500 to ~50 using directory-level inotify, incremental state persistence with per-session JSON caching, and replace team watcher polling with chokidar fs events
|
||||
|
||||
## 0.2.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Remove 159 lines of dead code: unused interfaces, functions, config constants, legacy no-op timer, and stale barrel re-exports
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix race condition in StateStore where dirty flag was overwritten after async write, silently discarding mutations
|
||||
- Fix PlanOrchestrator session leak by adding session.stop() in finally blocks and centralizing cleanup
|
||||
- Fix symlink path traversal in file-content and file-raw endpoints by adding realpathSync validation
|
||||
- Fix PTY exit handler to clean up sessionListenerRefs, transcriptWatchers, runSummaryTrackers, and terminal batching state
|
||||
- Fix sendInput() fire-and-forget by propagating runPrompt errors to task queue via taskError event
|
||||
- Fix Ralph Loop tick() race condition by running checkTimeouts/assignTasks sequentially with per-iteration error handling
|
||||
- Fix shell injection in hook scripts by piping HOOK_DATA via printf to curl stdin instead of inline embedding
|
||||
- Narrow tail-file allowlist to remove ~/.cache and ~/.local/share paths that exposed credentials
|
||||
- Fix stored XSS in quick-start dropdown by escaping case names with escapeHtml()
|
||||
|
||||
## 0.2.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Disable tunnel auto-start on boot; tunnel now only starts when user clicks the UI toggle
|
||||
|
||||
## 0.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix 3 minor memory leaks: clear respawn timers in stop(), clean up persistDebounceTimers on session cleanup, reset \_parentNameCache on SSE reconnect
|
||||
|
||||
## 0.2.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix tunnel button not working: settings PUT was rejected by strict Zod validation when sending full settings blob; now sends only `{tunnelEnabled}`. Added polling fallback for tunnel status in case SSE events are missed.
|
||||
|
||||
## 0.2.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix tunnel button stuck on "Connecting..." when tunnel is already running on the server
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Update CLAUDE.md app.js line count references
|
||||
|
||||
## 0.2.1
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|------|---------|
|
||||
| Dev server | `npx tsx src/index.ts web` |
|
||||
| Type check | `tsc --noEmit` |
|
||||
| Lint | `npm run lint` (fix: `npm run lint:fix`) |
|
||||
| Format | `npm run format` (check: `npm run format:check`) |
|
||||
| Single test | `npx vitest run test/<file>.test.ts` |
|
||||
| Production | `npm run build && systemctl --user restart codeman-web` |
|
||||
|
||||
@@ -15,7 +17,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
**You may be running inside a Codeman-managed tmux session.** Before killing ANY tmux or Claude process:
|
||||
|
||||
1. Check: `echo $CODEMAN_TMUX` - if `1`, you're in a managed session
|
||||
1. Check: `echo $CODEMAN_MUX` - if `1`, you're in a managed session
|
||||
2. **NEVER** run `tmux kill-session`, `pkill tmux`, or `pkill claude` without confirming
|
||||
3. Use the web UI or `./scripts/tmux-manager.sh` instead of direct kill commands
|
||||
|
||||
@@ -39,7 +41,7 @@ When user says "COM":
|
||||
```bash
|
||||
cat > .changeset/$(openssl rand -hex 4).md << 'CHANGESET'
|
||||
---
|
||||
"codeman": patch
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
Description of changes
|
||||
@@ -50,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.2.1 (must match `package.json`)
|
||||
**Version**: 0.2.9 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -64,7 +66,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
## Commands
|
||||
|
||||
**CRITICAL**: `npm run dev` shows CLI help, NOT the web server.
|
||||
**Note**: `npm run dev` starts the web server (equivalent to `npx tsx src/index.ts web`).
|
||||
|
||||
**Default port**: `3000` (web UI at `http://localhost:3000`)
|
||||
|
||||
@@ -77,25 +79,29 @@ npx tsx src/index.ts web # Dev server (RECOMMENDED)
|
||||
npx tsx src/index.ts web --https # With TLS (only needed for remote access)
|
||||
npm run typecheck # Type check
|
||||
tsc --noEmit --watch # Continuous type checking
|
||||
npm run lint # ESLint
|
||||
npm run lint:fix # ESLint with auto-fix
|
||||
npm run format # Prettier format
|
||||
npm run format:check # Prettier check only
|
||||
|
||||
# Testing (NEVER run full suite from inside Codeman — kills tmux sessions)
|
||||
# npx vitest run # ALL tests — DANGEROUS inside Codeman
|
||||
# Testing (see "Testing" section for CRITICAL safety warnings)
|
||||
npx vitest run test/<file>.test.ts # Single file (SAFE)
|
||||
npx vitest run -t "pattern" # Tests matching name
|
||||
npm run test:coverage # With coverage report
|
||||
|
||||
# Production
|
||||
npm run build
|
||||
npm run build # esbuild via scripts/build.mjs (not tsc)
|
||||
npm run start # node dist/index.js (production)
|
||||
systemctl --user restart codeman-web
|
||||
journalctl --user -u codeman-web -f
|
||||
```
|
||||
|
||||
**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, and `format:check` on push to master. Tests are intentionally excluded from CI (they spawn tmux).
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
- **`npm run dev` is NOT the web server** — it shows CLI help. Use `npx tsx src/index.ts web`
|
||||
- **Single-line prompts only** — `writeViaMux()` sends text and Enter separately; multi-line breaks Ink
|
||||
- **Don't kill tmux sessions blindly** — Check `$CODEMAN_TMUX` first; you might be inside one
|
||||
- **Never run full test suite** — `npx vitest run` spawns/kills tmux sessions and will crash your Codeman session. Run individual test files only.
|
||||
- **Don't kill tmux sessions blindly** — Check `$CODEMAN_MUX` first; you might be inside one
|
||||
- **Global regex `lastIndex` sharing** — `ANSI_ESCAPE_PATTERN_FULL/SIMPLE` have `g` flag; use `createAnsiPatternFull/Simple()` factory functions for fresh instances in loops
|
||||
- **DEC 2026 sync blocks** — Never discard incomplete sync blocks (START without END); buffer up to 50ms then flush. See `app.js:extractSyncSegments()`
|
||||
- **Terminal writes during buffer load** — Live SSE writes are queued while `_isLoadingBuffer` is true to prevent interleaving with historical data
|
||||
@@ -113,6 +119,7 @@ journalctl --user -u codeman-web -f
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/index.ts` | CLI entry point: global error recovery, uncaught exception guard, `MAX_CONSECUTIVE_ERRORS` auto-restart |
|
||||
| `src/session.ts` | PTY wrapper: `runPrompt()`, `startInteractive()`, `startShell()` |
|
||||
| `src/mux-interface.ts` | `TerminalMultiplexer` interface + `MuxSession` type |
|
||||
| `src/mux-factory.ts` | Create tmux multiplexer instance |
|
||||
@@ -143,10 +150,11 @@ journalctl --user -u codeman-web -f
|
||||
| `src/prompts/index.ts` | Barrel export for all agent prompts |
|
||||
| `src/prompts/*.ts` | Agent prompts (research-agent, planner) |
|
||||
| `src/templates/claude-md.ts` | CLAUDE.md generation for new cases |
|
||||
| `src/tunnel-manager.ts` | Manages cloudflared child process for Cloudflare tunnel remote access |
|
||||
| `src/cli.ts` | Command-line interface handlers |
|
||||
| `src/web/server.ts` | Fastify REST API + SSE at `/api/events` (~105 routes) |
|
||||
| `src/web/server.ts` | Fastify REST API + SSE at `/api/events` (~280 routes) |
|
||||
| `src/web/schemas.ts` | Zod v4 validation schemas with path/env security allowlists |
|
||||
| `src/web/public/app.js` | Frontend: xterm.js, tab management, subagent windows, mobile support (~17K lines) |
|
||||
| `src/web/public/app.js` | Frontend: xterm.js, tab management, subagent windows, mobile support (~15K lines) |
|
||||
| `src/types.ts` | All TypeScript interfaces (~70 type/interface/enum defs, ~1450 lines) |
|
||||
|
||||
**Large files** (>50KB): `app.js`, `ralph-tracker.ts`, `respawn-controller.ts`, `session.ts`, `subagent-watcher.ts` — these contain complex state machines; read `docs/respawn-state-machine.md` before modifying.
|
||||
@@ -164,22 +172,23 @@ journalctl --user -u codeman-web -f
|
||||
| `buffer-limits.ts` | Terminal/text buffer size limits |
|
||||
| `map-limits.ts` | Global limits for Maps, sessions, watchers |
|
||||
|
||||
### Utility Files (`src/utils/`)
|
||||
### Utilities (`src/utils/`)
|
||||
|
||||
| File | Purpose |
|
||||
Re-exported via `src/utils/index.ts`. Key exports:
|
||||
|
||||
| File | Exports |
|
||||
|------|---------|
|
||||
| `index.ts` | Re-exports all utilities (standard import point) |
|
||||
| `lru-map.ts` | LRU eviction Map for bounded caches |
|
||||
| `nice-wrapper.ts` | Wrap commands with `nice` priority adjustment |
|
||||
| `stale-expiration-map.ts` | TTL-based Map with lazy expiration |
|
||||
| `claude-cli-resolver.ts` | Resolve Claude CLI binary across install paths |
|
||||
| `cleanup-manager.ts` | Centralized resource disposal |
|
||||
| `buffer-accumulator.ts` | Chunk accumulator with size limits |
|
||||
| `string-similarity.ts` | String matching utilities (fuzzy matching) |
|
||||
| `token-validation.ts` | Token count parsing and validation |
|
||||
| `regex-patterns.ts` | Shared regex patterns for parsing |
|
||||
| `type-safety.ts` | `assertNever()` for exhaustive switch/case type checking |
|
||||
| `opencode-cli-resolver.ts` | Resolve OpenCode CLI binary across install paths |
|
||||
| `cleanup-manager.ts` | `CleanupManager` — centralized disposal for timers, intervals, watchers, listeners, streams |
|
||||
| `lru-map.ts` | `LRUMap` — bounded cache with eviction |
|
||||
| `stale-expiration-map.ts` | `StaleExpirationMap` — TTL-based map with automatic cleanup |
|
||||
| `regex-patterns.ts` | `ANSI_ESCAPE_PATTERN_FULL/SIMPLE`, `createAnsiPatternFull/Simple()`, `stripAnsi`, `TOKEN_PATTERN`, `SPINNER_PATTERN` |
|
||||
| `buffer-accumulator.ts` | `BufferAccumulator` — batches rapid writes into single flushes |
|
||||
| `claude-cli-resolver.ts` | `findClaudeDir`, `getAugmentedPath` — resolves Claude CLI paths |
|
||||
| `opencode-cli-resolver.ts` | `resolveOpenCodeDir`, `isOpenCodeAvailable` — OpenCode CLI support |
|
||||
| `string-similarity.ts` | `stringSimilarity`, `fuzzyPhraseMatch`, `todoContentHash` |
|
||||
| `token-validation.ts` | `validateTokenCounts`, `validateTokensAndCost` |
|
||||
| `nice-wrapper.ts` | `wrapWithNice` — wraps commands with `nice`/`ionice` for lower priority |
|
||||
| `type-safety.ts` | `assertNever` — exhaustive switch/case guard |
|
||||
|
||||
### Data Flow
|
||||
|
||||
@@ -215,7 +224,8 @@ journalctl --user -u codeman-web -f
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/web/public/index.html` | HTML entry point with inline critical CSS and async vendor loading |
|
||||
| `src/web/public/app.js` | Core UI: xterm.js, tab management, subagent windows, mobile support (~17.5K lines) |
|
||||
| `src/web/public/app.js` | Core UI: xterm.js, tab management, subagent windows, mobile support (~15K lines) |
|
||||
| `src/web/public/ralph-wizard.js` | Ralph Loop wizard UI extracted from app.js (~1K lines) |
|
||||
| `src/web/public/styles.css` | Main styling (dark theme, layout, components) |
|
||||
| `src/web/public/mobile.css` | Responsive overrides for screens <1024px (loaded conditionally via `media` attribute) |
|
||||
| `src/web/public/upload.html` | Screenshot upload page served at `/upload.html` |
|
||||
@@ -225,7 +235,7 @@ journalctl --user -u codeman-web -f
|
||||
|
||||
### Frontend Architecture (`app.js`)
|
||||
|
||||
The frontend is a single ~17.5K-line vanilla JS file with these key systems:
|
||||
The frontend is a single ~15K-line vanilla JS file with these key systems:
|
||||
|
||||
| System | Key Classes/Functions | Purpose |
|
||||
|--------|----------------------|---------|
|
||||
@@ -274,7 +284,7 @@ The frontend is a single ~17.5K-line vanilla JS file with these key systems:
|
||||
|
||||
### API Route Categories
|
||||
|
||||
~105 routes in `server.ts:buildServer()`. Key groups:
|
||||
~280 route handlers in `server.ts:buildServer()`. Key groups:
|
||||
|
||||
| Group | Prefix | Count | Key endpoints |
|
||||
|-------|--------|-------|---------------|
|
||||
@@ -431,58 +441,42 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base
|
||||
| **Ralph Loop guide** | `docs/ralph-wiggum-guide.md` |
|
||||
| **Claude Code hooks** | `docs/claude-code-hooks-reference.md` |
|
||||
| **Terminal anti-flicker** | `docs/terminal-anti-flicker.md` |
|
||||
| **API routes** | `src/web/server.ts:buildServer()` or README.md (full endpoint tables) |
|
||||
| **Agent Teams (experimental)** | `agent-teams/README.md`, `agent-teams/design.md` |
|
||||
| **API routes** | `src/web/server.ts:buildServer()` or README.md |
|
||||
| **SSE events** | Search `broadcast(` in `server.ts` |
|
||||
| **CLI commands** | `codeman --help` |
|
||||
| **Frontend patterns** | `src/web/public/app.js` (subagent windows, notifications) |
|
||||
| **Session statuses** | `SessionStatus` type in `src/types.ts` |
|
||||
| **Error codes** | `createErrorResponse()` in `src/types.ts` |
|
||||
| **Test utilities** | `test/respawn-test-utils.ts` |
|
||||
| **Memory leak patterns** | `test/memory-leak-prevention.test.ts` |
|
||||
| **Keyboard shortcuts** | README.md or App Settings in web UI |
|
||||
| **Mobile/SSH access** | README.md (Codeman Sessions / `sc` command) |
|
||||
| **Plan orchestrator** | `src/plan-orchestrator.ts` file header |
|
||||
| **Agent prompts** | `src/prompts/` directory |
|
||||
| **Agent Teams (experimental)** | `agent-teams/README.md`, `agent-teams/design.md` |
|
||||
| **Local echo overlay** | `docs/local-echo-overlay-plan.md` |
|
||||
| **Browser testing** | `docs/browser-testing-guide.md` |
|
||||
| **Mobile testing** | `docs/mobile-testing-report.md` |
|
||||
| **Run summary design** | `docs/run-summary-plan.md` |
|
||||
| **Performance audit** | `docs/perf-audit-first-load.md` |
|
||||
| **First-load optimization** | `docs/first-load-optimization-plan.md` |
|
||||
| **Dead code audit** | `docs/cleanup-findings.md` |
|
||||
| **Mobile test suite** | `mobile-test/README.md` |
|
||||
| **Voice input** | `docs/voice-input-plan.md` |
|
||||
| **Background keystroke forwarding** | `docs/background-keystroke-forwarding-merged-plan.md` |
|
||||
| **Respawn improvements** | `docs/respawn-improvement-plan.md` |
|
||||
| **Ralph improvements** | `docs/ralph-improvement-plan.md`, `docs/ralph-phase1-implementation.md` |
|
||||
| **Performance investigation** | `docs/performance-investigation-report.md` |
|
||||
| **Plan improvements** | `docs/plan-improvement-roadmap.md` |
|
||||
| **TypeScript improvements** | `docs/typescript-improvement-suggestions.md` |
|
||||
| **OpenCode integration** | `docs/opencode-integration.md` |
|
||||
| **Local echo overlay** | `docs/local-echo-overlay-plan.md` |
|
||||
| **Performance investigation** | `docs/performance-investigation-report.md` |
|
||||
| **First-load optimization** | `docs/first-load-optimization-plan.md`, `docs/perf-audit-first-load.md` |
|
||||
| **Dead code audit** | `docs/cleanup-findings.md` |
|
||||
| **TypeScript improvements** | `docs/typescript-improvement-suggestions.md` |
|
||||
| **Browser testing** | `docs/browser-testing-guide.md` |
|
||||
| **Mobile testing report** | `docs/mobile-testing-report.md` |
|
||||
| **Voice input** | `docs/voice-input-plan.md` |
|
||||
| **Improvement roadmaps** | `docs/respawn-improvement-plan.md`, `docs/ralph-improvement-plan.md`, `docs/plan-improvement-roadmap.md` |
|
||||
| **Background keystroke forwarding** | `docs/background-keystroke-forwarding-merged-plan.md` |
|
||||
| **Run summary** | `docs/run-summary-plan.md` |
|
||||
|
||||
Additional design docs and investigation reports are in the `docs/` directory.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/tmux-manager.sh` | Safe tmux session management (use instead of direct kill commands) |
|
||||
| `scripts/tmux-chooser.sh` | Mobile-friendly tmux session picker (`sc` alias) |
|
||||
| `scripts/monitor-respawn.sh` | Monitor respawn state machine in real-time |
|
||||
| `scripts/postinstall.js` | npm postinstall hook for setup |
|
||||
| `scripts/data-generator.sh` | Generate test data for development |
|
||||
| `scripts/test-tail-links.sh` | Test clickable file links in tail output |
|
||||
| `scripts/capture-subagent-screenshots.mjs` | Capture subagent screenshots for README (uses real Claude sessions) |
|
||||
| `scripts/capture-subagent-gif.mjs` | Capture subagent GIF animations for README |
|
||||
| `scripts/mobile-screenshot.mjs` | Capture mobile UI screenshots |
|
||||
| `scripts/ralph-wizard-start.mjs` | Automate Ralph Loop startup via headless browser |
|
||||
| `scripts/ralph-wizard-prod.mjs` | Production Ralph wizard with HTTPS support |
|
||||
| `scripts/browser-comparison.mjs` | Compare Playwright, Puppeteer, and Agent-Browser frameworks |
|
||||
| `scripts/ralph-wizard-demo.mjs` | Demo Ralph Loop wizard via visible browser |
|
||||
| `scripts/test-links-browser.mjs` | Browser test for clickable terminal file links |
|
||||
| `scripts/test-patterns.mjs` | Test file path link detection regex patterns |
|
||||
| `scripts/watch-subagents.ts` | Real-time subagent transcript watcher (list, follow by session/agent ID) |
|
||||
| `scripts/capture-readme-screenshots.mjs` | Capture screenshots for README |
|
||||
| `scripts/codeman-web.service` | systemd service file for production deployment |
|
||||
| `scripts/codeman-tunnel.service` | systemd service file for persistent Cloudflare tunnel |
|
||||
| `scripts/tunnel.sh` | Start/stop/check Cloudflare quick tunnel (`./scripts/tunnel.sh start\|stop\|url`) |
|
||||
| `scripts/build.mjs` | esbuild-based production build (called by `npm run build`) |
|
||||
| `scripts/postinstall.js` | npm postinstall hook for setup |
|
||||
|
||||
Additional scripts in `scripts/` for screenshots, demos, Ralph wizards, and browser testing.
|
||||
|
||||
## Memory Leak Prevention
|
||||
|
||||
|
||||
@@ -194,6 +194,53 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
|
||||
---
|
||||
|
||||
## Remote Access — Cloudflare Tunnel
|
||||
|
||||
Access Codeman from your phone or any device outside your local network using a free [Cloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/) — no port forwarding, no DNS, no static IP required.
|
||||
|
||||
```
|
||||
Browser (phone/tablet) → Cloudflare Edge (HTTPS) → cloudflared → localhost:3000
|
||||
```
|
||||
|
||||
**Prerequisites:** Install [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) and set `CODEMAN_PASSWORD` in your environment.
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
./scripts/tunnel.sh start # Start tunnel, prints public URL
|
||||
./scripts/tunnel.sh url # Show current URL
|
||||
./scripts/tunnel.sh stop # Stop tunnel
|
||||
./scripts/tunnel.sh status # Service status + URL
|
||||
```
|
||||
|
||||
The script auto-installs a systemd user service on first run. The tunnel URL is a randomly generated `*.trycloudflare.com` address that changes each time the tunnel restarts.
|
||||
|
||||
<details>
|
||||
<summary><strong>Persistent tunnel (survives reboots)</strong></summary>
|
||||
|
||||
```bash
|
||||
# Enable as a persistent service
|
||||
systemctl --user enable codeman-tunnel
|
||||
loginctl enable-linger $USER
|
||||
|
||||
# Or via the Codeman web UI: Settings → Tunnel → Toggle On
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Authentication</strong></summary>
|
||||
|
||||
1. First request → browser shows Basic Auth prompt (username: `admin` or `CODEMAN_USERNAME`)
|
||||
2. On success → server issues a `codeman_session` cookie (24h TTL, auto-extends on activity)
|
||||
3. Subsequent requests authenticate silently via cookie
|
||||
4. 10 failed attempts per IP → 429 rate limit (15-minute decay)
|
||||
|
||||
**Always set `CODEMAN_PASSWORD`** before exposing via tunnel — without it, anyone with the URL has full access to your sessions.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## SSH Alternative (`sc`)
|
||||
|
||||
If you prefer SSH (Termius, Blink, etc.), the `sc` command is a thumb-friendly session chooser:
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Performance & Responsiveness Optimization Plan
|
||||
|
||||
**Date**: 2026-02-28
|
||||
**Status**: In Progress
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Three independent research passes analyzed the Codeman codebase for performance bottlenecks across frontend rendering, backend hot paths, and system-level resource usage. The codebase already has strong foundational optimizations (per-session adaptive batching, rAF terminal writes, DEC 2026 sync markers, backpressure handling). This plan targets the remaining high-impact opportunities.
|
||||
|
||||
**Key finding**: The biggest wins come from **skipping unnecessary work** — serializing unchanged state, processing output nobody is watching, and reducing broadcast volume.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Quick Wins — ALREADY IMPLEMENTED
|
||||
|
||||
All Phase 1 items were found to already exist in the codebase during verification:
|
||||
|
||||
| # | Item | Status | Evidence |
|
||||
|---|------|--------|----------|
|
||||
| 1.1 | Skip terminal writes for hidden tabs | Done | SSE handler filters by `activeSessionId` (app.js:4076) |
|
||||
| 1.2 | mobile.css media query | Done | `media="(max-width: 1023px)"` on link tag (index.html:13) |
|
||||
| 1.3 | Deduplicate init API calls | Done | `_initGeneration` dedup + 3s fallback timer (app.js:2901-2904) |
|
||||
| 1.4 | Remove cache-busting timestamps | Done | No `?_t=` patterns found anywhere |
|
||||
| 1.5 | JS/CSS minification + compression | Done | esbuild minify + gzip + brotli in build.mjs (lines 42-51) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Frontend Responsiveness — MOSTLY ALREADY IMPLEMENTED
|
||||
|
||||
### 2.1 Batch `getBoundingClientRect()` in connection lines — DONE
|
||||
- **Files**: `src/web/public/app.js` (`_updateConnectionLinesImmediate()`)
|
||||
- **Change**: Refactored to batch all layout reads into Phase 1 (collect all rects into a Map), then perform all SVG writes in Phase 2 using cached values. Classic read-then-write pattern prevents interleaved forced reflows.
|
||||
|
||||
### 2.2 Clean up ResizeObservers — Already implemented
|
||||
- `forceCloseSubagentWindow()` disconnects observers (app.js:12618-12620)
|
||||
- `cleanupAllFloatingWindows()` disconnects all on reconnect (app.js:12649-12653)
|
||||
- Observer refs stored on `windowData.resizeObserver` (app.js:12492)
|
||||
|
||||
### 2.3 Drag handler cleanup — Already implemented
|
||||
- `makeWindowDraggable()` returns listener refs, stored in `windowData.dragListeners`
|
||||
- `forceCloseSubagentWindow()` removes all document-level drag listeners (app.js:12622-12630)
|
||||
- Panel drags add listeners on mousedown, remove on mouseup (app.js:10253-10284)
|
||||
|
||||
### 2.4 Mobile window position cache — Skipped
|
||||
- O(n) loop over max ~20 windows; complexity of cached counter not justified
|
||||
|
||||
### 2.5 Lazy modal DOM — Skipped
|
||||
- Large effort, marginal benefit for a vanilla JS app with fast DOM construction
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Backend Hot Paths
|
||||
|
||||
### 3.1 State diff broadcasts — ALREADY OPTIMIZED
|
||||
- `broadcastSessionStateDebounced()` already batches at 500ms intervals
|
||||
- `toLightDetailedState()` excludes heavy buffers (textOutput, terminalBuffer)
|
||||
- Per-session serialization is <1ms; with debouncing, only 1-3 sessions serialize per flush
|
||||
- JSON.stringify happens once per broadcast (not per client) — serialization cost is negligible
|
||||
- Full state diffs would add significant frontend complexity for marginal gain
|
||||
|
||||
### 3.2 Improve session list cache hit rate — DONE
|
||||
- **Files**: `src/web/server.ts` (`broadcast()` method)
|
||||
- **Change**: Cache now only invalidated on truly structural events (`session:created`, `session:deleted`, `session:updated`) instead of on every `session:*` and `respawn:*` event. High-frequency events like `session:working`, `session:idle`, `session:completion`, `respawn:stateChanged` no longer defeat the 1s TTL cache.
|
||||
- **Impact**: Cache hit ratio from ~0% to ~80%+ during active sessions. The debounced `session:updated` still refreshes the cache within 500ms of any state change.
|
||||
|
||||
### 3.3 Skip PTY processing — ALREADY OPTIMIZED
|
||||
- `_processExpensiveParsers()` is already throttled to every 150ms (not per-chunk)
|
||||
- Lazy ANSI stripping via `getCleanData()` closure — only computed when a consumer needs it
|
||||
- Quick pre-checks skip parsers when content is irrelevant (e.g., token parser only runs if data contains "token")
|
||||
- OpenCode sessions skip all Claude-specific parsers entirely
|
||||
- Further optimization would require visibility-aware processing, adding complexity for marginal gain
|
||||
|
||||
### 3.4 Batch subagent liveness checks — Deferred
|
||||
- `/proc/{pid}` stat calls are ~0.1ms each; even with 500 agents, total is 50ms every 10s
|
||||
- Current approach is simple and reliable; batching adds race condition risk
|
||||
- Consider only if profiling shows this as a bottleneck
|
||||
|
||||
### 3.5 Deduplicate detection update emissions — DONE
|
||||
- **Files**: `src/respawn-controller.ts` (`startDetectionUpdates()`)
|
||||
- **Change**: Detection status now only emitted when key fields (confidenceLevel, statusText, controller state) actually change. Previously emitted every 2s regardless, broadcasting identical status to all SSE clients.
|
||||
- **Impact**: For stable/idle sessions, eliminates ~100% of redundant detection broadcasts. For active sessions, reduces broadcasts to only meaningful state transitions.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: System-Level Improvements
|
||||
|
||||
### 4.1 Incremental state persistence
|
||||
- **Files**: `src/state-store.ts` (~lines 145-160)
|
||||
- **Problem**: Every 500ms debounce writes the entire `AppState` (all sessions, tasks, config) via `JSON.stringify()`. With 50 sessions, state can be tens of MB. Serialization alone costs 50-100ms.
|
||||
- **Fix**: Track dirty sessions. On persist, only re-serialize dirty sessions; cache serialized JSON for clean sessions. Assemble final output from cached fragments.
|
||||
- **Impact**: Reduces serialization cost from O(all sessions) to O(dirty sessions). Typical steady-state: 1-2 dirty sessions instead of 50.
|
||||
|
||||
### 4.2 Replace polling with fs watchers for team watcher
|
||||
- **Files**: `src/team-watcher.ts` (~lines 148-180)
|
||||
- **Problem**: Polls `~/.claude/teams/` every 5s via `readdir()` + `stat()`. Blocks event loop for 100-200ms on large directories.
|
||||
- **Fix**: Use `chokidar` (already a dependency) or `fs.watch()` to react to changes. Keep a 30s fallback poll for reliability.
|
||||
- **Impact**: Eliminates 5s polling overhead; near-instant team detection.
|
||||
|
||||
### 4.3 Consolidate subagent file watchers
|
||||
- **Files**: `src/subagent-watcher.ts` (~line 229+)
|
||||
- **Problem**: One chokidar watcher per agent directory. With 500 agents, that's 500 inotify watchers consuming kernel resources.
|
||||
- **Fix**: Watch at the session level (one watcher per session's subagent directory), not per-agent. Parse events to route to correct agent.
|
||||
- **Impact**: Reduces inotify watchers from 500 to ~50 (one per session).
|
||||
|
||||
### 4.4 Stream transcript files instead of full reads
|
||||
- **Files**: `src/subagent-watcher.ts` (~lines 959-964)
|
||||
- **Problem**: `loadTranscript()` reads entire transcript file (can be >100KB). With 500 agents discovered at once, that's 50MB of file reads.
|
||||
- **Fix**: Only read last 10KB for display (tail). Full file on-demand only (e.g., when user opens transcript viewer).
|
||||
- **Impact**: Reduces file I/O from 50MB to 5MB for bulk agent discovery.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Long-Term Architectural (Optional)
|
||||
|
||||
### 5.1 Worker thread for PTY processing
|
||||
- **Files**: `src/session.ts`
|
||||
- **Problem**: ANSI stripping, Ralph tracking, and bash tool parsing all run on the main event loop. At scale (50 busy sessions), this consumes 300-500ms CPU/sec.
|
||||
- **Fix**: Offload ANSI strip + line processing to a worker thread pool. Main thread receives clean text + parsed events.
|
||||
- **Impact**: Frees event loop for I/O operations. Most impactful at 10+ concurrent busy sessions.
|
||||
|
||||
### 5.2 Per-session SSE subscriptions
|
||||
- **Files**: `src/web/server.ts`
|
||||
- **Problem**: Every SSE event is broadcast to all connected clients. A client watching session A still receives events for sessions B through Z.
|
||||
- **Fix**: Clients subscribe to specific session IDs. Server only sends events to interested clients.
|
||||
- **Impact**: Reduces SSE broadcast fan-out from N clients to ~1-2 per event. Major improvement at 100 SSE clients.
|
||||
|
||||
### 5.3 O(1) LRUMap via doubly-linked list
|
||||
- **Files**: `src/utils/lru-map.ts` (~lines 98-110)
|
||||
- **Problem**: `get()` uses delete + re-insert to refresh position — O(n) on Map iteration for delete.
|
||||
- **Fix**: Implement classic LRU with doubly-linked list + Map for O(1) get/put/evict.
|
||||
- **Impact**: Low — current sizes (max 500) make this barely measurable. Only worthwhile if LRUMap is used on hot paths.
|
||||
|
||||
---
|
||||
|
||||
## Priority Matrix (Remaining Work)
|
||||
|
||||
| # | Item | Impact | Risk | Effort |
|
||||
|---|------|--------|------|--------|
|
||||
| 3.1 | State diff broadcasts | **Very High** | Medium | 3-4h |
|
||||
| 3.2 | Fix session cache invalidation | **High** | Low | 1h |
|
||||
| 3.3 | Skip PTY processing for hidden sessions | **High** | Medium | 2-3h |
|
||||
| 3.5 | Throttle detection broadcasts | **Medium** | Low | 1h |
|
||||
| 3.4 | Batch liveness checks | **Medium** | Low | 1-2h |
|
||||
| 4.1 | Incremental state persistence | **Medium** | Medium | 3-4h |
|
||||
| 4.2 | Team watcher fs events | **Low-Med** | Medium | 2h |
|
||||
| 4.3 | Consolidate file watchers | **Low-Med** | Medium | 2h |
|
||||
| 4.4 | Stream transcripts | **Low-Med** | Low | 1h |
|
||||
| 5.1 | Worker thread PTY | **Med** (at scale) | High | 8h |
|
||||
| 5.2 | Per-session SSE subs | **Med** (at scale) | High | 4h |
|
||||
| 5.3 | O(1) LRUMap | **Very Low** | Medium | 2h |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Execution Order
|
||||
|
||||
**Sprint 1** (Phase 3 — Backend Hot Paths): Items 3.1, 3.2, 3.3, 3.5
|
||||
- Backend serialization and broadcast efficiency
|
||||
- Highest remaining impact; requires careful testing with multiple active sessions
|
||||
|
||||
**Sprint 2** (Phase 4 — System Level): Items 4.1, 3.4, 4.3, 4.4
|
||||
- State persistence, liveness checks, watcher consolidation
|
||||
- Medium-complexity refactors
|
||||
|
||||
**Sprint 3** (Phase 5 — Architectural): Items 5.1, 5.2 — only if scaling demands it
|
||||
|
||||
---
|
||||
|
||||
## Measurement
|
||||
|
||||
Before starting implementation, establish baselines:
|
||||
|
||||
1. **Frontend**: Record Chrome DevTools Performance trace with 10 sessions open. Measure:
|
||||
- Frame rate during rapid terminal output
|
||||
- Long tasks (>50ms) count per 30s
|
||||
- Heap size after 1h session
|
||||
|
||||
2. **Backend**: Add `performance.now()` instrumentation around:
|
||||
- `flushSessionTerminalBatch()` — time per flush
|
||||
- `broadcastSessionStateDebounced()` — serialization time
|
||||
- `StateStore.save()` — persist time
|
||||
- Event loop lag via `monitorEventLoopDelay()`
|
||||
|
||||
3. **First load**: Lighthouse score on desktop and mobile (simulated 3G)
|
||||
@@ -0,0 +1,29 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'no-debugger': 'error',
|
||||
// Relax some rules that conflict with existing patterns
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'off', // TypeScript compiler already handles this
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'node_modules/**',
|
||||
'coverage/**',
|
||||
'src/web/public/vendor/**',
|
||||
'src/web/public/app.js',
|
||||
'scripts/**/*.mjs',
|
||||
'tools/**',
|
||||
'remotion/**',
|
||||
],
|
||||
}
|
||||
);
|
||||
@@ -1,29 +1,37 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"version": "0.2.1",
|
||||
"name": "aicodeman",
|
||||
"version": "0.2.9",
|
||||
"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",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"codeman": "./dist/index.js"
|
||||
"aicodeman": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/postinstall.js",
|
||||
"build": "tsc && chmod +x dist/index.js && mkdir -p dist/web dist/templates dist/web/public/vendor && cp -r src/web/public dist/web/ && cp src/templates/case-template.md dist/templates/ && cp node_modules/xterm/css/xterm.css dist/web/public/vendor/ && npx esbuild node_modules/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js && npx esbuild node_modules/xterm-addon-fit/lib/xterm-addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js && cp node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js && npx esbuild node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js && npx esbuild dist/web/public/app.js --minify --drop:console --outfile=dist/web/public/app.js --allow-overwrite && npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite && npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite && for f in dist/web/public/*.js dist/web/public/*.css dist/web/public/*.html dist/web/public/vendor/*.js dist/web/public/vendor/*.css; do [ -f \"$f\" ] && gzip -9 -k -f \"$f\" && { brotli -9 -k -f \"$f\" 2>/dev/null || true; }; done",
|
||||
"build": "node scripts/build.mjs",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts",
|
||||
"dev": "tsx src/index.ts web",
|
||||
"web": "node dist/index.js web",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
||||
"format": "prettier --write 'src/**/*.ts'",
|
||||
"format:check": "prettier --check 'src/**/*.ts'",
|
||||
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
|
||||
"changeset": "changeset",
|
||||
"version-packages": "changeset version",
|
||||
"release": "changeset publish"
|
||||
},
|
||||
"workspaces": [
|
||||
".",
|
||||
"packages/*"
|
||||
],
|
||||
"keywords": [
|
||||
"claude",
|
||||
"claude-code",
|
||||
@@ -43,15 +51,12 @@
|
||||
"@fastify/compress": "^8.3.1",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/static": "^8.0.0",
|
||||
"@remotion/cli": "4.0.429",
|
||||
"@remotion/transitions": "4.0.429",
|
||||
"chalk": "^5.3.0",
|
||||
"chokidar": "^3.6.0",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^5.1.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"remotion": "4.0.429",
|
||||
"uuid": "^10.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"xterm": "^5.3.0",
|
||||
@@ -62,6 +67,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.29.8",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@remotion/cli": "4.0.429",
|
||||
"@remotion/transitions": "4.0.429",
|
||||
"@types/node": "^20.19.33",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/react": "^19.2.14",
|
||||
@@ -70,12 +78,16 @@
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"agent-browser": "^0.6.0",
|
||||
"esbuild": "^0.27.3",
|
||||
"eslint": "^9.0.0",
|
||||
"pixelmatch": "^6.0.0",
|
||||
"playwright": "^1.58.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"prettier": "^3.4.0",
|
||||
"puppeteer": "^24.36.0",
|
||||
"remotion": "4.0.429",
|
||||
"tsx": "^4.15.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,399 +0,0 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "P0-001",
|
||||
"content": "Write failing tests for SessionMode type extension — verify 'opencode' is accepted as a valid mode in SessionMode, OpenCodeConfig interface exists with model/provider/autoAllowTools/continueSession/serverPort fields, and MuxSession.mode accepts 'opencode'",
|
||||
"priority": "P0",
|
||||
"tddPhase": "test",
|
||||
"verificationCriteria": "test/opencode-types.test.ts exists, tests fail because SessionMode doesn't include 'opencode' and OpenCodeConfig doesn't exist",
|
||||
"testCommand": "npx vitest run test/opencode-types.test.ts",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "P0-002",
|
||||
"content": "Implement SessionMode type extension — extend SessionMode from 'claude' | 'shell' to 'claude' | 'shell' | 'opencode' in types.ts (line 162), add OpenCodeConfig interface with fields: model?: string, provider?: string, autoAllowTools?: boolean, continueSession?: string, serverPort?: number. Update MuxSession.mode and createSession/respawnPane signatures in mux-interface.ts to accept 'opencode' and optional openCodeConfig param",
|
||||
"priority": "P0",
|
||||
"tddPhase": "impl",
|
||||
"verificationCriteria": "npx vitest run test/opencode-types.test.ts passes, tsc --noEmit succeeds",
|
||||
"pairedWith": "P0-001",
|
||||
"dependencies": ["P0-001"]
|
||||
},
|
||||
{
|
||||
"id": "P0-003",
|
||||
"content": "Review type system changes — verify OpenCodeConfig follows existing type conventions (optional fields, no Claude-specific coupling), SessionMode union is used consistently across types.ts/mux-interface.ts/session.ts, no breaking changes to existing code",
|
||||
"priority": "P0",
|
||||
"tddPhase": "review",
|
||||
"verificationCriteria": "tsc --noEmit passes with no errors, grep confirms SessionMode used consistently, no 'claude' | 'shell' hardcoded literals remain in interface definitions",
|
||||
"reviewChecklist": ["Type consistency across files", "No breaking changes to existing Claude/shell modes", "OpenCodeConfig fields match opencode CLI flags", "Optional fields properly typed"],
|
||||
"pairedWith": "P0-002",
|
||||
"dependencies": ["P0-002"]
|
||||
},
|
||||
{
|
||||
"id": "P0-004",
|
||||
"content": "Write failing tests for OpenCode CLI resolver — test resolveOpenCodeDir() returns directory when opencode binary exists, returns null when not found, caches result after first call, isOpenCodeAvailable() returns boolean, getOpenCodeAugmentedPath() prepends directory to PATH. Mock filesystem and execSync. Port: none (pure unit test)",
|
||||
"priority": "P0",
|
||||
"tddPhase": "test",
|
||||
"verificationCriteria": "test/opencode-resolver.test.ts exists with 5+ test cases, all fail because opencode-cli-resolver.ts doesn't exist",
|
||||
"testCommand": "npx vitest run test/opencode-resolver.test.ts",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"id": "P0-005",
|
||||
"content": "Implement opencode-cli-resolver.ts — create src/utils/opencode-cli-resolver.ts mirroring claude-cli-resolver.ts pattern. Functions: resolveOpenCodeDir() (checks which opencode, then ~/.local/bin, /usr/local/bin, ~/.bun/bin, ~/.npm-global/bin, ~/bin, ~/.opencode/bin), isOpenCodeAvailable(), getOpenCodeAugmentedPath(). Cache results. Add re-export in src/utils/index.ts",
|
||||
"priority": "P0",
|
||||
"tddPhase": "impl",
|
||||
"verificationCriteria": "npx vitest run test/opencode-resolver.test.ts passes, tsc --noEmit succeeds",
|
||||
"pairedWith": "P0-004",
|
||||
"dependencies": ["P0-004"]
|
||||
},
|
||||
{
|
||||
"id": "P0-006",
|
||||
"content": "Review CLI resolver implementation — verify no command injection in execSync('which opencode'), timeout set to 5s, graceful fallback when binary not found, caching works correctly (null vs empty string sentinel), PATH augmentation doesn't duplicate entries",
|
||||
"priority": "P0",
|
||||
"tddPhase": "review",
|
||||
"verificationCriteria": "Code review passes: no shell injection, proper error handling, cache invalidation, consistent with claude-cli-resolver.ts patterns",
|
||||
"reviewChecklist": ["No command injection in execSync", "Proper timeout handling", "Cache sentinel value for 'not found'", "PATH deduplication", "Re-export in utils/index.ts"],
|
||||
"pairedWith": "P0-005",
|
||||
"dependencies": ["P0-005"]
|
||||
},
|
||||
{
|
||||
"id": "P0-007",
|
||||
"content": "Write failing tests for Zod schema validation — test CreateSessionSchema accepts mode: 'opencode', accepts openCodeConfig object with model/provider/autoAllowTools/continueSession fields, rejects invalid model strings (shell metacharacters), rejects overly long model names (>100 chars), validates provider field. Port: none (pure unit test)",
|
||||
"priority": "P0",
|
||||
"tddPhase": "test",
|
||||
"verificationCriteria": "test/opencode-schema.test.ts exists, tests fail because schema doesn't accept 'opencode' mode or openCodeConfig field",
|
||||
"testCommand": "npx vitest run test/opencode-schema.test.ts",
|
||||
"dependencies": ["P0-002"]
|
||||
},
|
||||
{
|
||||
"id": "P0-008",
|
||||
"content": "Implement schema validation changes — update CreateSessionSchema in src/web/schemas.ts: extend mode enum to include 'opencode', add openCodeConfig z.object with model (string, max 100, regex /^[a-zA-Z0-9._\\-/]+$/), provider (string, max 50), autoAllowTools (boolean), continueSession (string, max 100, regex /^[a-zA-Z0-9_-]+$/). All fields optional",
|
||||
"priority": "P0",
|
||||
"tddPhase": "impl",
|
||||
"verificationCriteria": "npx vitest run test/opencode-schema.test.ts passes, tsc --noEmit succeeds",
|
||||
"pairedWith": "P0-007",
|
||||
"dependencies": ["P0-007"]
|
||||
},
|
||||
{
|
||||
"id": "P0-009",
|
||||
"content": "Review schema validation — verify model regex blocks shell metacharacters (;|&$`), continueSession regex blocks path traversal, no overly permissive patterns, consistent with existing SAFE_PATH_PATTERN security approach in schemas.ts",
|
||||
"priority": "P0",
|
||||
"tddPhase": "review",
|
||||
"verificationCriteria": "Schema rejects all dangerous inputs: model with semicolons, backticks, pipes; continueSession with ../; empty strings handled properly",
|
||||
"reviewChecklist": ["Shell metacharacter blocking", "Path traversal prevention", "Consistent with existing security patterns", "Zod v4 API usage correct"],
|
||||
"pairedWith": "P0-008",
|
||||
"dependencies": ["P0-008"]
|
||||
},
|
||||
{
|
||||
"id": "P1-001",
|
||||
"content": "Write failing tests for TmuxManager opencode command construction — test buildOpenCodeCommand() generates correct CLI: basic 'opencode' command, with --model flag, with --session flag for continue, validates model string safety, validates session ID safety. Test that createSession() with mode 'opencode' builds correct tmux respawn-pane command with opencode env vars (CLAUDEMAN_MUX, API keys passthrough). Mock execAsync. Port: none (unit test with mocks)",
|
||||
"priority": "P1",
|
||||
"tddPhase": "test",
|
||||
"verificationCriteria": "test/opencode-tmux.test.ts exists with 8+ test cases covering command construction, env var setup, and PATH augmentation for opencode mode",
|
||||
"testCommand": "npx vitest run test/opencode-tmux.test.ts",
|
||||
"dependencies": ["P0-002", "P0-005"]
|
||||
},
|
||||
{
|
||||
"id": "P1-002",
|
||||
"content": "Implement TmuxManager opencode support — in src/tmux-manager.ts: (1) import resolveOpenCodeDir from utils, (2) add buildOpenCodeCommand(sessionId, config?) helper that constructs 'opencode [--model X] [--session Y]' with input validation, (3) extend createSession() command construction to handle mode === 'opencode' alongside existing claude/shell branches, (4) add opencode-specific env exports (CLAUDEMAN_MUX, CLAUDEMAN_SESSION_ID, API key passthrough for ANTHROPIC/OPENAI/GOOGLE_API_KEY, optional OPENCODE_CONFIG_CONTENT), (5) use resolveOpenCodeDir() for PATH augmentation when mode is opencode, (6) throw clear error if opencode binary not found, (7) apply same changes to respawnPane()",
|
||||
"priority": "P1",
|
||||
"tddPhase": "impl",
|
||||
"verificationCriteria": "npx vitest run test/opencode-tmux.test.ts passes, tsc --noEmit succeeds",
|
||||
"pairedWith": "P1-001",
|
||||
"dependencies": ["P1-001"]
|
||||
},
|
||||
{
|
||||
"id": "P1-003",
|
||||
"content": "Review TmuxManager opencode integration — verify command injection prevention in buildOpenCodeCommand (model and sessionId validated before interpolation), env var escaping for OPENCODE_CONFIG_CONTENT (single quotes properly escaped), API key passthrough doesn't leak other env vars, respawnPane changes mirror createSession exactly, error messages are actionable",
|
||||
"priority": "P1",
|
||||
"tddPhase": "review",
|
||||
"verificationCriteria": "No command injection vectors, env vars properly escaped, consistent with existing Claude command construction security, error messages guide user to install opencode",
|
||||
"reviewChecklist": ["Command injection prevention", "Env var escaping (single quotes in config content)", "API key passthrough scope limited", "respawnPane mirrors createSession", "Error message includes install instructions"],
|
||||
"pairedWith": "P1-002",
|
||||
"dependencies": ["P1-002"]
|
||||
},
|
||||
{
|
||||
"id": "P1-004",
|
||||
"content": "Write failing tests for Session class opencode mode — test that Session constructor accepts mode: 'opencode' with openCodeConfig, test startInteractive() dispatches to opencode-specific startup (not Claude CLI), test that Claude-specific features are disabled for opencode sessions (hooks config skipped, subagent watcher Claude patterns skipped), test that writeViaMux() works identically for opencode mode (tmux send-keys is mode-agnostic). Port: 3161",
|
||||
"priority": "P1",
|
||||
"tddPhase": "test",
|
||||
"verificationCriteria": "test/opencode-session.test.ts exists with 6+ test cases, tests fail because Session doesn't support opencode mode",
|
||||
"testCommand": "npx vitest run test/opencode-session.test.ts",
|
||||
"dependencies": ["P0-002", "P1-002"]
|
||||
},
|
||||
{
|
||||
"id": "P1-005",
|
||||
"content": "Implement Session class opencode support — in src/session.ts: (1) accept openCodeConfig in constructor options and store as private field, (2) in startInteractive(), when mode is 'opencode', pass openCodeConfig to mux.createSession(), (3) skip hooks config generation for opencode sessions (no .claude/settings.local.json hooks), (4) add waitForOpenCodeReady() method using output-silence detection (wait for TUI paint: 500ms of stable output after initial burst, max 8s), (5) skip Claude-specific status line parsing for opencode mode, (6) add mode getter to Session for downstream consumers",
|
||||
"priority": "P1",
|
||||
"tddPhase": "impl",
|
||||
"verificationCriteria": "npx vitest run test/opencode-session.test.ts passes, tsc --noEmit succeeds",
|
||||
"pairedWith": "P1-004",
|
||||
"dependencies": ["P1-004"]
|
||||
},
|
||||
{
|
||||
"id": "P1-006",
|
||||
"content": "Review Session class opencode integration — verify opencode mode doesn't break existing Claude/shell sessions (no regressions), waitForOpenCodeReady timeout is reasonable (8s), hooks are properly skipped without breaking Claude hooks, no memory leaks from new fields, toState() includes opencode-relevant state",
|
||||
"priority": "P1",
|
||||
"tddPhase": "review",
|
||||
"verificationCriteria": "Existing session tests still pass, opencode-specific logic properly guarded with mode checks, no side effects on Claude sessions",
|
||||
"reviewChecklist": ["No regression on Claude/shell modes", "waitForOpenCodeReady timeout appropriate", "Hooks skipped cleanly", "toState() serialization works", "No memory leak from new fields"],
|
||||
"pairedWith": "P1-005",
|
||||
"dependencies": ["P1-005"]
|
||||
},
|
||||
{
|
||||
"id": "P1-007",
|
||||
"content": "Write failing tests for opencode idle detection — test getIdleDetectionConfig() returns opencode-specific config (silenceThresholdMs: 5000, no prompt pattern, no AI checker), test that session emits 'idle' after 5s of output silence in opencode mode, test that session emits 'working' when output resumes, test that idle detection works during respawn cycles. Port: 3162",
|
||||
"priority": "P1",
|
||||
"tddPhase": "test",
|
||||
"verificationCriteria": "test/opencode-idle.test.ts exists with 4+ test cases testing silence-based idle detection for opencode mode",
|
||||
"testCommand": "npx vitest run test/opencode-idle.test.ts",
|
||||
"dependencies": ["P1-005"]
|
||||
},
|
||||
{
|
||||
"id": "P1-008",
|
||||
"content": "Implement opencode idle detection — in src/session.ts: (1) add getIdleDetectionConfig() method returning mode-specific config: for opencode — silenceThresholdMs: 5000, promptPattern: null, useAIChecker: false; for claude — existing values, (2) modify idle detection logic to use silence-based detection when promptPattern is null (track lastOutputTime, timer-based idle check), (3) ensure 'working' event fires on any new output after idle state, (4) make IDLE_DETECTION_DELAY_MS configurable per mode",
|
||||
"priority": "P1",
|
||||
"tddPhase": "impl",
|
||||
"verificationCriteria": "npx vitest run test/opencode-idle.test.ts passes, opencode sessions correctly transition idle→working→idle based on output silence",
|
||||
"pairedWith": "P1-007",
|
||||
"dependencies": ["P1-007"]
|
||||
},
|
||||
{
|
||||
"id": "P1-009",
|
||||
"content": "Review idle detection implementation — verify silence timer is properly cleaned up on session stop/exit (no leaked timers), timer doesn't fire after session disposal, idle threshold is tunable per session, working→idle transition doesn't spam events, existing Claude idle detection unchanged",
|
||||
"priority": "P1",
|
||||
"tddPhase": "review",
|
||||
"verificationCriteria": "Timer cleanup verified, no event spam, Claude idle detection regression-free, CleanupManager tracks new timer",
|
||||
"reviewChecklist": ["Timer cleanup on session.stop()", "No event spam on rapid output", "Claude idle detection unchanged", "CleanupManager integration", "Configurable threshold"],
|
||||
"pairedWith": "P1-008",
|
||||
"dependencies": ["P1-008"]
|
||||
},
|
||||
{
|
||||
"id": "P1-010",
|
||||
"content": "Write failing tests for API routes — test POST /api/sessions creates opencode session when mode: 'opencode', test returns 400 when opencode not installed, test GET /api/opencode/status returns availability info, test POST /api/sessions/:id/interactive works for opencode sessions, test openCodeConfig is persisted in session state. Port: 3163",
|
||||
"priority": "P1",
|
||||
"tddPhase": "test",
|
||||
"verificationCriteria": "test/opencode-api.test.ts exists with 5+ test cases, tests fail because server doesn't handle opencode mode",
|
||||
"testCommand": "npx vitest run test/opencode-api.test.ts",
|
||||
"dependencies": ["P0-008", "P1-005"]
|
||||
},
|
||||
{
|
||||
"id": "P1-011",
|
||||
"content": "Implement API routes for opencode — in src/web/server.ts: (1) in POST /api/sessions handler, check isOpenCodeAvailable() when mode is 'opencode' and return 400 if not installed, pass openCodeConfig from request body to Session constructor, (2) add GET /api/opencode/status route returning { available: boolean, path: string | null }, (3) ensure POST /api/sessions/:id/interactive works for opencode mode (startInteractive already handles mode dispatch), (4) include mode in session state broadcast events, (5) persist openCodeConfig in state store",
|
||||
"priority": "P1",
|
||||
"tddPhase": "impl",
|
||||
"verificationCriteria": "npx vitest run test/opencode-api.test.ts passes, curl GET /api/opencode/status returns valid JSON",
|
||||
"pairedWith": "P1-010",
|
||||
"dependencies": ["P1-010"]
|
||||
},
|
||||
{
|
||||
"id": "P1-012",
|
||||
"content": "Review API routes — verify /api/opencode/status doesn't expose sensitive info (only binary path, not env vars), session creation validates all openCodeConfig fields before passing to Session, error messages don't leak internal paths, SSE events include mode for frontend routing, state persistence includes openCodeConfig",
|
||||
"priority": "P1",
|
||||
"tddPhase": "review",
|
||||
"verificationCriteria": "No info leakage, proper validation, error messages user-friendly, SSE events tagged with mode",
|
||||
"reviewChecklist": ["No sensitive info in /api/opencode/status", "openCodeConfig validated before use", "Error messages actionable", "SSE events include mode", "State persistence round-trips correctly"],
|
||||
"pairedWith": "P1-011",
|
||||
"dependencies": ["P1-011"]
|
||||
},
|
||||
{
|
||||
"id": "P1-013",
|
||||
"content": "Write failing tests for frontend mode selector — Playwright test: load app, verify session creation dialog includes 'OpenCode' option alongside 'Claude Code' and 'Shell', verify selecting OpenCode shows model input field, verify OpenCode option is disabled when /api/opencode/status returns available: false, verify tab shows 'OC' badge for opencode sessions. Port: 3164",
|
||||
"priority": "P1",
|
||||
"tddPhase": "test",
|
||||
"verificationCriteria": "test/opencode-frontend.test.ts (Playwright) exists with 4+ assertions, tests fail because UI doesn't have OpenCode option",
|
||||
"testCommand": "npx vitest run test/opencode-frontend.test.ts",
|
||||
"dependencies": ["P1-011"]
|
||||
},
|
||||
{
|
||||
"id": "P1-014",
|
||||
"content": "Implement frontend UI changes — in src/web/public/app.js: (1) add OpenCode to session creation mode selector (in quick-start modal and/or new session dialog), with icon and 'Multi-model AI agent' description, (2) when opencode mode selected, show model text input with autocomplete for common models (anthropic/claude-sonnet-4-5, openai/gpt-5.2, google/gemini-3-pro, ollama/codellama), (3) check /api/opencode/status on load and disable option if unavailable, (4) add tab badge rendering: 'OC' badge with green (#10b981) background for opencode sessions, 'SH' for shell, none for claude, (5) gate Claude-specific UI panels for opencode sessions (disable hooks panel, auto-compact button)",
|
||||
"priority": "P1",
|
||||
"tddPhase": "impl",
|
||||
"verificationCriteria": "npx vitest run test/opencode-frontend.test.ts passes, manual verification: mode selector shows OpenCode option, tab badge renders",
|
||||
"pairedWith": "P1-013",
|
||||
"dependencies": ["P1-013"]
|
||||
},
|
||||
{
|
||||
"id": "P1-015",
|
||||
"content": "Review frontend implementation — verify mode selector accessibility (keyboard navigable, ARIA labels), model autocomplete doesn't make excessive API calls, tab badge CSS follows existing z-index layering, disabled state has clear visual indicator, no XSS from model name rendering (text content, not innerHTML), feature gating doesn't break Claude session UI",
|
||||
"priority": "P1",
|
||||
"tddPhase": "review",
|
||||
"verificationCriteria": "Accessible UI, no XSS vectors, existing Claude UI unchanged, CSS consistent with design system",
|
||||
"reviewChecklist": ["Keyboard accessibility", "No XSS from model names", "CSS z-index consistent", "Claude UI regression-free", "Disabled state visual clarity", "Mobile layout compatibility"],
|
||||
"pairedWith": "P1-014",
|
||||
"dependencies": ["P1-014"]
|
||||
},
|
||||
{
|
||||
"id": "P1-016",
|
||||
"content": "Write failing tests for respawn controller opencode adaptation — using MockSession from test/respawn-test-utils.ts: test respawn controller accepts opencode sessions, test completion detection uses output silence (not 'Worked for' pattern) for opencode, test prompt sending via writeViaMux works for opencode, test circuit breaker works identically for opencode sessions. Port: none (uses MockSession)",
|
||||
"priority": "P1",
|
||||
"tddPhase": "test",
|
||||
"verificationCriteria": "test/opencode-respawn.test.ts exists with 4+ test cases using MockSession, tests fail because respawn controller doesn't handle opencode idle detection",
|
||||
"testCommand": "npx vitest run test/opencode-respawn.test.ts",
|
||||
"dependencies": ["P1-008"]
|
||||
},
|
||||
{
|
||||
"id": "P1-017",
|
||||
"content": "Implement respawn controller opencode adaptation — in src/respawn-controller.ts: (1) add mode-aware completion detection: for opencode, use output silence threshold instead of COMPLETION_TIME_PATTERN regex, (2) skip plan mode detection patterns for opencode (OpenCode has different plan mode), (3) skip AI idle checker for opencode sessions initially (prompt is Claude-specific), (4) ensure prompt sending via writeViaMux works without modification (it's mode-agnostic), (5) keep circuit breaker, health scoring, and cycle metrics unchanged (they're mode-agnostic)",
|
||||
"priority": "P1",
|
||||
"tddPhase": "impl",
|
||||
"verificationCriteria": "npx vitest run test/opencode-respawn.test.ts passes, respawn cycles work with silence-based completion detection",
|
||||
"pairedWith": "P1-016",
|
||||
"dependencies": ["P1-016"]
|
||||
},
|
||||
{
|
||||
"id": "P1-018",
|
||||
"content": "Review respawn controller changes — verify existing Claude respawn behavior unchanged (run existing respawn tests), silence-based detection has reasonable timeout (matches idle detection config), no race conditions between idle detection and respawn timer, circuit breaker still functions correctly for opencode sessions",
|
||||
"priority": "P1",
|
||||
"tddPhase": "review",
|
||||
"verificationCriteria": "Existing respawn tests pass, no regressions, silence timeout configurable, race conditions addressed",
|
||||
"reviewChecklist": ["Existing Claude respawn tests pass", "Silence timeout reasonable", "No race conditions", "Circuit breaker works for opencode", "Respawn config serialization includes mode"],
|
||||
"pairedWith": "P1-017",
|
||||
"dependencies": ["P1-017"]
|
||||
},
|
||||
{
|
||||
"id": "P1-019",
|
||||
"content": "Write failing tests for state persistence round-trip — test that opencode sessions are correctly serialized to ~/.claudeman/state.json (mode, openCodeConfig preserved), test that sessions are restored on server restart with correct mode and config, test that session lifecycle log records opencode mode. Port: none (unit test)",
|
||||
"priority": "P1",
|
||||
"tddPhase": "test",
|
||||
"verificationCriteria": "test/opencode-state.test.ts exists with 3+ test cases, tests verify serialization/deserialization of opencode session state",
|
||||
"testCommand": "npx vitest run test/opencode-state.test.ts",
|
||||
"dependencies": ["P1-005"]
|
||||
},
|
||||
{
|
||||
"id": "P1-020",
|
||||
"content": "Implement state persistence for opencode sessions — in src/state-store.ts: ensure openCodeConfig is included in SessionState serialization, in session.ts toState() method include openCodeConfig, in server.ts restoreSession() handle mode: 'opencode' and pass openCodeConfig through, update session-lifecycle-log.ts to record opencode mode in lifecycle entries",
|
||||
"priority": "P1",
|
||||
"tddPhase": "impl",
|
||||
"verificationCriteria": "npx vitest run test/opencode-state.test.ts passes, state.json correctly stores and restores opencode sessions",
|
||||
"pairedWith": "P1-019",
|
||||
"dependencies": ["P1-019"]
|
||||
},
|
||||
{
|
||||
"id": "P1-021",
|
||||
"content": "Review state persistence — verify openCodeConfig doesn't contain sensitive data (API keys not serialized to disk), state.json schema is backward compatible (existing sessions load fine without openCodeConfig), lifecycle log entries are queryable by mode",
|
||||
"priority": "P1",
|
||||
"tddPhase": "review",
|
||||
"verificationCriteria": "No API keys in state.json, backward compatibility verified, lifecycle log works",
|
||||
"reviewChecklist": ["No secrets in state.json", "Backward compatibility", "Lifecycle log queryable by mode", "toState() round-trip fidelity"],
|
||||
"pairedWith": "P1-020",
|
||||
"dependencies": ["P1-020"]
|
||||
},
|
||||
{
|
||||
"id": "P2-001",
|
||||
"content": "Write failing tests for feature gating — test that hooks config is NOT generated for opencode sessions, test that subagent watcher skips Claude-specific transcript patterns for opencode, test that auto-compact sends correct slash command per mode (/compact for claude, /clear for opencode), test that token tracking is disabled for opencode sessions (no status line parsing). Port: none (unit tests)",
|
||||
"priority": "P2",
|
||||
"tddPhase": "test",
|
||||
"verificationCriteria": "test/opencode-feature-gating.test.ts exists with 4+ test cases verifying mode-aware feature behavior",
|
||||
"testCommand": "npx vitest run test/opencode-feature-gating.test.ts",
|
||||
"dependencies": ["P1-005"]
|
||||
},
|
||||
{
|
||||
"id": "P2-002",
|
||||
"content": "Implement feature gating for opencode sessions — (1) in hooks-config.ts: guard generateHooksConfig() to skip when session mode is 'opencode', (2) in session.ts: skip token tracking / status line parsing for opencode mode, (3) in session.ts: make auto-compact command mode-aware (Claude: /compact, OpenCode: skip or /clear), (4) in subagent-watcher.ts: skip Claude-specific transcript parsing for opencode sessions but still watch for generic agent activity patterns",
|
||||
"priority": "P2",
|
||||
"tddPhase": "impl",
|
||||
"verificationCriteria": "npx vitest run test/opencode-feature-gating.test.ts passes, Claude features properly disabled for opencode sessions",
|
||||
"pairedWith": "P2-001",
|
||||
"dependencies": ["P2-001"]
|
||||
},
|
||||
{
|
||||
"id": "P2-003",
|
||||
"content": "Review feature gating — verify all mode checks are consistent (use session.mode, not hardcoded string comparisons scattered everywhere), no Claude features accidentally leak into opencode sessions, no opencode-specific code paths break Claude sessions",
|
||||
"priority": "P2",
|
||||
"tddPhase": "review",
|
||||
"verificationCriteria": "Consistent mode checking pattern, no feature leakage, no Claude regressions",
|
||||
"reviewChecklist": ["Consistent mode check pattern", "No feature leakage", "No Claude regressions", "Auto-compact behavior correct per mode"],
|
||||
"pairedWith": "P2-002",
|
||||
"dependencies": ["P2-002"]
|
||||
},
|
||||
{
|
||||
"id": "P2-004",
|
||||
"content": "Write end-to-end integration test — Playwright test that creates an opencode session via the web UI (if opencode is installed) or via API with mocked binary, verifies terminal renders, sends input, receives output, verifies tab badge shows 'OC', verifies session appears in /api/sessions with mode: 'opencode', cleans up session. Port: 3165",
|
||||
"priority": "P2",
|
||||
"tddPhase": "test",
|
||||
"verificationCriteria": "test/opencode-e2e.test.ts exists with full session lifecycle test, skips gracefully if opencode binary not installed",
|
||||
"testCommand": "npx vitest run test/opencode-e2e.test.ts",
|
||||
"dependencies": ["P1-014", "P1-011"]
|
||||
},
|
||||
{
|
||||
"id": "P2-005",
|
||||
"content": "Fix any issues found during E2E testing — address xterm.js rendering issues with OpenCode's Bubble Tea TUI (if any), fix signal handling (SIGWINCH for resize), resolve any env var passthrough issues, ensure cleanup on session delete works correctly for opencode sessions",
|
||||
"priority": "P2",
|
||||
"tddPhase": "impl",
|
||||
"verificationCriteria": "npx vitest run test/opencode-e2e.test.ts passes end-to-end, manual smoke test confirms TUI renders correctly in browser",
|
||||
"pairedWith": "P2-004",
|
||||
"dependencies": ["P2-004"]
|
||||
},
|
||||
{
|
||||
"id": "P2-006",
|
||||
"content": "Review E2E integration — verify session cleanup doesn't leave orphaned tmux sessions, no zombie processes, state.json is clean after deletion, SSE events fire correctly for all lifecycle phases (created, interactive, idle, working, exit, deleted)",
|
||||
"priority": "P2",
|
||||
"tddPhase": "review",
|
||||
"verificationCriteria": "No orphaned tmux sessions, no zombies, clean state.json after delete, all SSE events fire",
|
||||
"reviewChecklist": ["No orphaned tmux sessions", "No zombie processes", "State cleanup complete", "SSE event lifecycle complete", "Resource usage reasonable"],
|
||||
"pairedWith": "P2-005",
|
||||
"dependencies": ["P2-005"]
|
||||
},
|
||||
{
|
||||
"id": "P2-007",
|
||||
"content": "Write tests for Ralph Loop opencode compatibility — test that Ralph Loop can send prompts to opencode sessions via writeViaMux, test that completion detection uses silence-based approach, test that todo tracking is disabled for opencode (no TodoWrite tool parsing), test that Ralph queue processes tasks for opencode sessions",
|
||||
"priority": "P2",
|
||||
"tddPhase": "test",
|
||||
"verificationCriteria": "test/opencode-ralph.test.ts exists with 4+ test cases using MockSession configured in opencode mode",
|
||||
"testCommand": "npx vitest run test/opencode-ralph.test.ts",
|
||||
"dependencies": ["P1-017"]
|
||||
},
|
||||
{
|
||||
"id": "P2-008",
|
||||
"content": "Implement Ralph Loop opencode compatibility — in ralph-loop.ts: (1) allow opencode sessions to be Ralph Loop targets, (2) skip promise phrase detection for opencode (no <promise> tags), (3) use silence-based completion detection matching respawn controller approach, (4) skip TodoWrite parsing for opencode sessions, (5) keep task queue and priority logic unchanged (mode-agnostic)",
|
||||
"priority": "P2",
|
||||
"tddPhase": "impl",
|
||||
"verificationCriteria": "npx vitest run test/opencode-ralph.test.ts passes, Ralph Loop can drive opencode sessions through task queues",
|
||||
"pairedWith": "P2-007",
|
||||
"dependencies": ["P2-007"]
|
||||
},
|
||||
{
|
||||
"id": "P2-009",
|
||||
"content": "Review Ralph Loop adaptation — verify Ralph Loop reliability for opencode (silence detection may be less precise than completion phrase), verify no Ralph features accidentally break for Claude sessions, verify circuit breaker properly handles opencode-specific failure modes",
|
||||
"priority": "P2",
|
||||
"tddPhase": "review",
|
||||
"verificationCriteria": "Ralph Loop works reliably for both modes, no Claude regressions, clear documentation of opencode limitations",
|
||||
"reviewChecklist": ["Silence detection reliability", "No Claude regressions in Ralph", "Circuit breaker handles opencode failures", "Task queue mode-agnostic"],
|
||||
"pairedWith": "P2-008",
|
||||
"dependencies": ["P2-008"]
|
||||
},
|
||||
{
|
||||
"id": "P2-010",
|
||||
"content": "Final typecheck and comprehensive review — run tsc --noEmit across entire project, verify all new files follow import conventions (utils from ./utils, types via type imports), verify no unused imports/variables (noUnusedLocals/noUnusedParameters), run existing test files individually to confirm no regressions, update CLAUDE.md with opencode session documentation",
|
||||
"priority": "P2",
|
||||
"tddPhase": "review",
|
||||
"verificationCriteria": "tsc --noEmit passes with zero errors, existing tests pass, CLAUDE.md updated with opencode section",
|
||||
"reviewChecklist": ["tsc --noEmit clean", "Import conventions followed", "No unused variables", "Existing tests pass", "CLAUDE.md updated", "No TODO/FIXME left unaddressed"],
|
||||
"dependencies": ["P2-003", "P2-006", "P2-009"]
|
||||
}
|
||||
],
|
||||
"gaps": [
|
||||
"OpenCode binary not currently installed on this machine — manual installation required before integration testing",
|
||||
"OpenCode's exact TUI escape sequence behavior with xterm.js is unknown — may need rendering fixes",
|
||||
"OpenCode's signal handling (SIGWINCH for resize, SIGTERM for graceful shutdown) needs empirical testing",
|
||||
"OpenCode's stdin behavior for pasted/programmatic text input is unverified — writeViaMux may need adaptation",
|
||||
"Token/cost tracking for opencode sessions is deferred — no structured way to get this from TUI output",
|
||||
"OpenCode's 'opencode serve' API integration (Strategy B) is not included in this plan — it's a separate follow-up project",
|
||||
"OpenCode was archived in September 2025 and moved to 'Crush' — long-term maintenance risk",
|
||||
"Multi-line input compatibility with OpenCode's TUI is unknown (Claudeman sends single-line via writeViaMux)",
|
||||
"AI idle checker prompt needs OpenCode-specific variant if AI-based idle detection is desired later",
|
||||
"Agent Teams integration with OpenCode sessions is not covered — teams are Claude Code-specific"
|
||||
],
|
||||
"warnings": [
|
||||
"OpenCode's GitHub repository was archived (Sep 2025) and the project moved to 'Crush' — consider whether to target opencode or crush",
|
||||
"Silence-based idle detection is less precise than Claude's prompt marker detection — may cause false positives (TUI animation) or false negatives (long-running quiet operations)",
|
||||
"The respawn controller's AI idle checker uses a Claude-specific prompt — it will be disabled for opencode, reducing idle detection reliability",
|
||||
"OpenCode's Bubble Tea TUI uses alternate screen buffer which may interact poorly with xterm.js buffer capture",
|
||||
"API key passthrough in tmux env exports means keys appear in tmux's process tree — security consideration for shared machines",
|
||||
"opencode.json in the project root may conflict with Claudeman's CLI flag overrides — need clear precedence documentation",
|
||||
"Ralph Loop with opencode may be less reliable without completion phrase detection — silence-based detection has higher error margin",
|
||||
"Test port allocation: this plan uses ports 3161-3165 — verify no conflicts with existing tests before implementation"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build script for Codeman.
|
||||
* Extracted from the package.json one-liner for readability and debuggability.
|
||||
*
|
||||
* Steps:
|
||||
* 1. TypeScript compilation
|
||||
* 2. Copy static assets (web/public, templates)
|
||||
* 3. Build vendor xterm bundles
|
||||
* 4. Minify frontend assets (app.js, styles.css, mobile.css)
|
||||
* 5. Compress with gzip + brotli
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { join } from 'path';
|
||||
|
||||
const ROOT = join(fileURLToPath(import.meta.url), '..', '..');
|
||||
|
||||
function run(label, cmd) {
|
||||
console.log(`\n[build] ${label}`);
|
||||
execSync(cmd, { stdio: 'inherit', cwd: ROOT, shell: true });
|
||||
}
|
||||
|
||||
// 1. TypeScript compilation
|
||||
run('tsc', 'tsc');
|
||||
run('chmod dist/index.js', 'chmod +x dist/index.js');
|
||||
|
||||
// 2. Copy static assets
|
||||
run('prepare dirs', 'mkdir -p dist/web dist/templates dist/web/public/vendor');
|
||||
run('copy web assets', 'cp -r src/web/public dist/web/');
|
||||
run('copy template', 'cp src/templates/case-template.md dist/templates/');
|
||||
|
||||
// 3. Vendor xterm bundles
|
||||
run('xterm css', 'cp node_modules/xterm/css/xterm.css dist/web/public/vendor/');
|
||||
run('xterm js', 'npx esbuild node_modules/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
|
||||
run('xterm-addon-fit', 'npx esbuild node_modules/xterm-addon-fit/lib/xterm-addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
|
||||
run('xterm-addon-webgl', 'cp node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
|
||||
run('xterm-addon-unicode11', 'npx esbuild node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
|
||||
|
||||
// 4. Minify frontend assets
|
||||
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --drop:console --outfile=dist/web/public/app.js --allow-overwrite');
|
||||
run('minify styles.css', 'npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite');
|
||||
run('minify mobile.css', 'npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite');
|
||||
|
||||
// 5. Compress with gzip + brotli
|
||||
run(
|
||||
'compress',
|
||||
`for f in dist/web/public/*.js dist/web/public/*.css dist/web/public/*.html dist/web/public/vendor/*.js dist/web/public/vendor/*.css; do` +
|
||||
` [ -f "$f" ] && gzip -9 -k -f "$f" && { brotli -9 -k -f "$f" 2>/dev/null || true; }; done`
|
||||
);
|
||||
|
||||
console.log('\n✓ Build complete');
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"remotion-best-practices": {
|
||||
"source": "remotion-dev/skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "9851afb52e1b892c43b2af973bda1776ce01fedfdfe704e0cfd70069a3a9c300"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export abstract class AiCheckerBase<
|
||||
V extends string,
|
||||
C extends AiCheckerConfigBase,
|
||||
R extends AiCheckerResultBase<V>,
|
||||
S extends AiCheckerStateBase<V>
|
||||
S extends AiCheckerStateBase<V>,
|
||||
> extends EventEmitter {
|
||||
protected config: C;
|
||||
protected sessionId: string;
|
||||
@@ -205,9 +205,7 @@ export abstract class AiCheckerBase<
|
||||
super();
|
||||
this.sessionId = sessionId;
|
||||
// Filter out undefined values to prevent overwriting defaults
|
||||
const filteredConfig = Object.fromEntries(
|
||||
Object.entries(config).filter(([, v]) => v !== undefined)
|
||||
) as Partial<C>;
|
||||
const filteredConfig = Object.fromEntries(Object.entries(config).filter(([, v]) => v !== undefined)) as Partial<C>;
|
||||
this.config = { ...defaultConfig, ...filteredConfig };
|
||||
}
|
||||
|
||||
@@ -342,9 +340,7 @@ export abstract class AiCheckerBase<
|
||||
/** Update configuration at runtime */
|
||||
updateConfig(config: Partial<C>): void {
|
||||
// Filter out undefined values to prevent overwriting existing config
|
||||
const filteredConfig = Object.fromEntries(
|
||||
Object.entries(config).filter(([, v]) => v !== undefined)
|
||||
) as Partial<C>;
|
||||
const filteredConfig = Object.fromEntries(Object.entries(config).filter(([, v]) => v !== undefined)) as Partial<C>;
|
||||
this.config = { ...this.config, ...filteredConfig };
|
||||
if (config.enabled === false) {
|
||||
this.disable('Disabled by config');
|
||||
@@ -369,9 +365,8 @@ export abstract class AiCheckerBase<
|
||||
|
||||
// Prepare the terminal buffer (strip ANSI, trim to maxContextChars)
|
||||
const stripped = terminalBuffer.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '');
|
||||
const trimmed = stripped.length > this.config.maxContextChars
|
||||
? stripped.slice(-this.config.maxContextChars)
|
||||
: stripped;
|
||||
const trimmed =
|
||||
stripped.length > this.config.maxContextChars ? stripped.slice(-this.config.maxContextChars) : stripped;
|
||||
|
||||
// Build the prompt
|
||||
const prompt = this.buildPrompt(trimmed);
|
||||
@@ -411,16 +406,15 @@ export abstract class AiCheckerBase<
|
||||
// No existing session, that's fine
|
||||
}
|
||||
|
||||
const muxProcess = childSpawn('tmux', [
|
||||
'new-session', '-d', '-s', this.checkMuxName,
|
||||
'bash', '-c', fullCmd
|
||||
], {
|
||||
const muxProcess = childSpawn('tmux', ['new-session', '-d', '-s', this.checkMuxName, 'bash', '-c', fullCmd], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
muxProcess.unref();
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to spawn ${this.checkDescription} tmux session: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw new Error(
|
||||
`Failed to spawn ${this.checkDescription} tmux session: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Poll the temp file for completion
|
||||
@@ -530,7 +524,9 @@ export abstract class AiCheckerBase<
|
||||
|
||||
private handleError(errorMsg: string): void {
|
||||
this.consecutiveErrors++;
|
||||
this.log(`${this.checkDescription} error (${this.consecutiveErrors}/${this.config.maxConsecutiveErrors}): ${errorMsg}`);
|
||||
this.log(
|
||||
`${this.checkDescription} error (${this.consecutiveErrors}/${this.config.maxConsecutiveErrors}): ${errorMsg}`
|
||||
);
|
||||
|
||||
if (this.consecutiveErrors >= this.config.maxConsecutiveErrors) {
|
||||
this.disable(`${this.config.maxConsecutiveErrors} consecutive errors: ${errorMsg}`);
|
||||
|
||||
@@ -33,13 +33,13 @@ import {
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface AiIdleCheckConfig extends AiCheckerConfigBase {}
|
||||
export type AiIdleCheckConfig = AiCheckerConfigBase;
|
||||
|
||||
export type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR';
|
||||
|
||||
export interface AiCheckResult extends AiCheckerResultBase<AiCheckVerdict> {}
|
||||
export type AiCheckResult = AiCheckerResultBase<AiCheckVerdict>;
|
||||
|
||||
export interface AiCheckState extends AiCheckerStateBase<AiCheckVerdict> {}
|
||||
export type AiCheckState = AiCheckerStateBase<AiCheckVerdict>;
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
@@ -123,12 +123,7 @@ Remember: When uncertain, answer WORKING.`;
|
||||
* Manages AI-powered idle detection by spawning a fresh Claude CLI session
|
||||
* to analyze terminal output and provide a definitive IDLE/WORKING verdict.
|
||||
*/
|
||||
export class AiIdleChecker extends AiCheckerBase<
|
||||
AiCheckVerdict,
|
||||
AiIdleCheckConfig,
|
||||
AiCheckResult,
|
||||
AiCheckState
|
||||
> {
|
||||
export class AiIdleChecker extends AiCheckerBase<AiCheckVerdict, AiIdleCheckConfig, AiCheckResult, AiCheckState> {
|
||||
protected readonly muxNamePrefix = 'codeman-aicheck-';
|
||||
protected readonly doneMarker = '__AICHECK_DONE__';
|
||||
protected readonly tempFilePrefix = 'codeman-aicheck';
|
||||
|
||||
@@ -32,13 +32,13 @@ import {
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface AiPlanCheckConfig extends AiCheckerConfigBase {}
|
||||
export type AiPlanCheckConfig = AiCheckerConfigBase;
|
||||
|
||||
export type AiPlanCheckVerdict = 'PLAN_MODE' | 'NOT_PLAN_MODE' | 'ERROR';
|
||||
|
||||
export interface AiPlanCheckResult extends AiCheckerResultBase<AiPlanCheckVerdict> {}
|
||||
export type AiPlanCheckResult = AiCheckerResultBase<AiPlanCheckVerdict>;
|
||||
|
||||
export interface AiPlanCheckState extends AiCheckerStateBase<AiPlanCheckVerdict> {}
|
||||
export type AiPlanCheckState = AiCheckerStateBase<AiPlanCheckVerdict>;
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
|
||||
@@ -73,25 +73,25 @@ const FOLLOW_MODE_PATTERN = /\s-[A-Za-z]*f[A-Za-z]*\s|\s--follow\s/;
|
||||
* Note: This is a simpler approach - we run it on each command string
|
||||
* rather than trying to match globally.
|
||||
*/
|
||||
const FILE_PATH_PATTERN = /(?:^|\s|['"]|=)([\/~][^\s'"<>|;&\n]+)/g;
|
||||
const FILE_PATH_PATTERN = /(?:^|\s|['"]|=)([/~][^\s'"<>|;&\n]+)/g;
|
||||
|
||||
/**
|
||||
* Pattern to detect paths that are likely not real files (flags, etc.)
|
||||
*/
|
||||
const INVALID_PATH_PATTERN = /^[\/~]-|\/dev\/null$/;
|
||||
const INVALID_PATH_PATTERN = /^[/~]-|\/dev\/null$/;
|
||||
|
||||
/**
|
||||
* Pattern to detect command suggestions in plain text output.
|
||||
* Matches lines like "tail -f /path/to/file" without the ● Bash() wrapper.
|
||||
* This catches commands Claude mentions but doesn't execute.
|
||||
*/
|
||||
const TEXT_COMMAND_PATTERN = /^\s*(tail|cat|head|less|grep|watch|multitail)\s+(?:-[^\s]+\s+)*([\/~][^\s'"<>|;&\n]+)/;
|
||||
const TEXT_COMMAND_PATTERN = /^\s*(tail|cat|head|less|grep|watch|multitail)\s+(?:-[^\s]+\s+)*([/~][^\s'"<>|;&\n]+)/;
|
||||
|
||||
/**
|
||||
* Pattern to detect log file paths mentioned in text (even without commands).
|
||||
* Matches paths ending in .log, .txt, .out, or in common log directories.
|
||||
*/
|
||||
const LOG_FILE_MENTION_PATTERN = /([\/~][^\s'"<>|;&\n]*(?:\.log|\.txt|\.out|\/log\/[^\s'"<>|;&\n]+))/g;
|
||||
const LOG_FILE_MENTION_PATTERN = /([/~][^\s'"<>|;&\n]*(?:\.log|\.txt|\.out|\/log\/[^\s'"<>|;&\n]+))/g;
|
||||
|
||||
// ========== Event Interfaces ==========
|
||||
|
||||
@@ -245,7 +245,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
*/
|
||||
private isShallowRootPath(path: string): boolean {
|
||||
if (!path.startsWith('/')) return false;
|
||||
const parts = path.split('/').filter(p => p !== '');
|
||||
const parts = path.split('/').filter((p) => p !== '');
|
||||
return parts.length === 1;
|
||||
}
|
||||
|
||||
@@ -354,10 +354,10 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
isFilePathTracked(filePath: string): boolean {
|
||||
const normalizedNew = this.normalizePath(filePath);
|
||||
|
||||
return Array.from(this._activeTools.values()).some(t => {
|
||||
return Array.from(this._activeTools.values()).some((t) => {
|
||||
if (t.status !== 'running') return false;
|
||||
|
||||
return t.filePaths.some(existingPath => {
|
||||
return t.filePaths.some((existingPath) => {
|
||||
const normalizedExisting = this.normalizePath(existingPath);
|
||||
return normalizedExisting === normalizedNew;
|
||||
});
|
||||
@@ -418,9 +418,8 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
// Prevent unbounded growth
|
||||
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
|
||||
const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
|
||||
this._lineBuffer = trimPoint > 0
|
||||
? this._lineBuffer.slice(trimPoint + 1)
|
||||
: this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
|
||||
this._lineBuffer =
|
||||
trimPoint > 0 ? this._lineBuffer.slice(trimPoint + 1) : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
|
||||
}
|
||||
|
||||
// Process complete lines
|
||||
@@ -445,9 +444,8 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
|
||||
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
|
||||
const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
|
||||
this._lineBuffer = trimPoint > 0
|
||||
? this._lineBuffer.slice(trimPoint + 1)
|
||||
: this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
|
||||
this._lineBuffer =
|
||||
trimPoint > 0 ? this._lineBuffer.slice(trimPoint + 1) : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
|
||||
}
|
||||
|
||||
const lines = this._lineBuffer.split('\n');
|
||||
@@ -472,7 +470,6 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
* Process a single pre-stripped line of terminal output.
|
||||
*/
|
||||
private processCleanLine(cleanLine: string): void {
|
||||
|
||||
// Check for tool start
|
||||
const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
|
||||
if (startMatch) {
|
||||
@@ -484,7 +481,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
const filePaths = this.extractFilePaths(command);
|
||||
|
||||
// Skip if any file path is already tracked (cross-pattern dedup)
|
||||
if (filePaths.some(fp => this.isFilePathTracked(fp))) {
|
||||
if (filePaths.some((fp) => this.isFilePathTracked(fp))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -502,8 +499,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
// Enforce max tools limit
|
||||
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
|
||||
// Remove oldest tool
|
||||
const oldest = Array.from(this._activeTools.entries())
|
||||
.sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
|
||||
const oldest = Array.from(this._activeTools.entries()).sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
|
||||
if (oldest) {
|
||||
this._activeTools.delete(oldest[0]);
|
||||
}
|
||||
@@ -671,6 +667,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
*/
|
||||
private stripAnsi(str: string): string {
|
||||
// Comprehensive ANSI pattern
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g, '');
|
||||
}
|
||||
|
||||
|
||||
@@ -21,17 +21,11 @@ const pkg = require('../package.json') as { version: string };
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('codeman')
|
||||
.description('Claude Code session manager with autonomous Ralph Loop')
|
||||
.version(pkg.version);
|
||||
program.name('codeman').description('Claude Code session manager with autonomous Ralph Loop').version(pkg.version);
|
||||
|
||||
// ============ Session Commands ============
|
||||
|
||||
const sessionCmd = program
|
||||
.command('session')
|
||||
.alias('s')
|
||||
.description('Manage Claude sessions');
|
||||
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
|
||||
|
||||
sessionCmd
|
||||
.command('start')
|
||||
@@ -83,11 +77,12 @@ sessionCmd
|
||||
console.log(' (none)');
|
||||
} else {
|
||||
for (const session of sessions) {
|
||||
const status = session.status === 'idle'
|
||||
? chalk.green('idle')
|
||||
: session.status === 'busy'
|
||||
? chalk.yellow('busy')
|
||||
: chalk.red(session.status);
|
||||
const status =
|
||||
session.status === 'idle'
|
||||
? chalk.green('idle')
|
||||
: session.status === 'busy'
|
||||
? chalk.yellow('busy')
|
||||
: chalk.red(session.status);
|
||||
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`);
|
||||
}
|
||||
}
|
||||
@@ -106,11 +101,12 @@ sessionCmd
|
||||
if (sessions.length === 0 && activeSessions.length > 0) {
|
||||
console.log(chalk.bold('\nActive Sessions (from web server):'));
|
||||
for (const session of activeSessions) {
|
||||
const status = session.status === 'idle'
|
||||
? chalk.green('idle')
|
||||
: session.status === 'busy'
|
||||
? chalk.yellow('busy')
|
||||
: chalk.red(session.status);
|
||||
const status =
|
||||
session.status === 'idle'
|
||||
? chalk.green('idle')
|
||||
: session.status === 'busy'
|
||||
? chalk.yellow('busy')
|
||||
: chalk.red(session.status);
|
||||
const name = session.name ? ` (${session.name})` : '';
|
||||
const mode = session.mode === 'shell' ? chalk.gray(' [shell]') : '';
|
||||
const cost = session.totalCost ? chalk.gray(` $${session.totalCost.toFixed(4)}`) : '';
|
||||
@@ -126,9 +122,7 @@ sessionCmd
|
||||
.option('-e, --errors', 'Show stderr instead of stdout')
|
||||
.action((id, options) => {
|
||||
const manager = getSessionManager();
|
||||
const output = options.errors
|
||||
? manager.getSessionError(id)
|
||||
: manager.getSessionOutput(id);
|
||||
const output = options.errors ? manager.getSessionError(id) : manager.getSessionOutput(id);
|
||||
|
||||
if (output === null) {
|
||||
console.log(chalk.yellow(`Session ${id} not found or not active`));
|
||||
@@ -145,10 +139,7 @@ sessionCmd
|
||||
|
||||
// ============ Task Commands ============
|
||||
|
||||
const taskCmd = program
|
||||
.command('task')
|
||||
.alias('t')
|
||||
.description('Manage tasks');
|
||||
const taskCmd = program.command('task').alias('t').description('Manage tasks');
|
||||
|
||||
taskCmd
|
||||
.command('add <prompt>')
|
||||
@@ -205,7 +196,9 @@ taskCmd
|
||||
|
||||
const counts = queue.getCount();
|
||||
console.log(chalk.bold('\nSummary:'));
|
||||
console.log(` Pending: ${counts.pending}, Running: ${counts.running}, Completed: ${counts.completed}, Failed: ${counts.failed}`);
|
||||
console.log(
|
||||
` Pending: ${counts.pending}, Running: ${counts.running}, Completed: ${counts.completed}, Failed: ${counts.failed}`
|
||||
);
|
||||
console.log('');
|
||||
});
|
||||
|
||||
@@ -276,10 +269,7 @@ taskCmd
|
||||
|
||||
// ============ Ralph Loop Commands ============
|
||||
|
||||
const ralphCmd = program
|
||||
.command('ralph')
|
||||
.alias('r')
|
||||
.description('Control the Ralph autonomous loop');
|
||||
const ralphCmd = program.command('ralph').alias('r').description('Control the Ralph autonomous loop');
|
||||
|
||||
ralphCmd
|
||||
.command('start')
|
||||
@@ -355,17 +345,16 @@ ralphCmd
|
||||
});
|
||||
|
||||
function printStats(stats: ReturnType<ReturnType<typeof getRalphLoop>['getStats']>) {
|
||||
const statusColor =
|
||||
stats.status === 'running' ? chalk.green :
|
||||
stats.status === 'paused' ? chalk.yellow :
|
||||
chalk.gray;
|
||||
const statusColor = stats.status === 'running' ? chalk.green : stats.status === 'paused' ? chalk.yellow : chalk.gray;
|
||||
|
||||
console.log(chalk.bold('\nRalph Loop Status:'));
|
||||
console.log(` Status: ${statusColor(stats.status)}`);
|
||||
console.log(` Elapsed: ${stats.elapsedHours.toFixed(2)} hours`);
|
||||
if (stats.minDurationMs) {
|
||||
const minHours = stats.minDurationMs / (1000 * 60 * 60);
|
||||
console.log(` Min Duration: ${minHours.toFixed(2)} hours (${stats.minDurationReached ? 'reached' : 'not reached'})`);
|
||||
console.log(
|
||||
` Min Duration: ${minHours.toFixed(2)} hours (${stats.minDurationReached ? 'reached' : 'not reached'})`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(chalk.bold('\nTasks:'));
|
||||
@@ -422,10 +411,7 @@ program
|
||||
console.log(` Completed: ${taskCounts.completed}`);
|
||||
console.log(` Failed: ${taskCounts.failed}`);
|
||||
|
||||
const statusColor =
|
||||
loopStatus === 'running' ? chalk.green :
|
||||
loopStatus === 'paused' ? chalk.yellow :
|
||||
chalk.gray;
|
||||
const statusColor = loopStatus === 'running' ? chalk.green : loopStatus === 'paused' ? chalk.yellow : chalk.gray;
|
||||
console.log(chalk.bold('\nRalph Loop:'));
|
||||
console.log(` Status: ${statusColor(loopStatus)}`);
|
||||
console.log('');
|
||||
@@ -481,11 +467,12 @@ program
|
||||
console.log(' (none)');
|
||||
} else {
|
||||
for (const session of sessions) {
|
||||
const status = session.status === 'idle'
|
||||
? chalk.green('idle')
|
||||
: session.status === 'busy'
|
||||
? chalk.yellow('busy')
|
||||
: chalk.red(session.status);
|
||||
const status =
|
||||
session.status === 'idle'
|
||||
? chalk.green('idle')
|
||||
: session.status === 'busy'
|
||||
? chalk.yellow('busy')
|
||||
: chalk.red(session.status);
|
||||
console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,26 +85,3 @@ export const MAX_RESPAWN_BUFFER_SIZE = 1 * 1024 * 1024; // 1MB
|
||||
* Size to trim respawn buffer to when max is exceeded.
|
||||
*/
|
||||
export const TRIM_RESPAWN_BUFFER_TO = 512 * 1024; // 512KB
|
||||
|
||||
// ============================================================================
|
||||
// Run Summary Limits
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Maximum number of events to keep in run summary.
|
||||
*/
|
||||
export const MAX_RUN_SUMMARY_EVENTS = 1000;
|
||||
|
||||
/**
|
||||
* Number of events to keep when trimming run summary.
|
||||
*/
|
||||
export const TRIM_RUN_SUMMARY_TO = 800;
|
||||
|
||||
// ============================================================================
|
||||
// Spawn Message Limits
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Maximum messages per spawn communication channel.
|
||||
*/
|
||||
export const MAX_MESSAGES_PER_CHANNEL = 100;
|
||||
|
||||
@@ -14,28 +14,6 @@
|
||||
* @module config/map-limits
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Agent Tracking Limits
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Maximum number of agents to track across all sessions.
|
||||
* Oldest agents are evicted when limit is exceeded (LRU policy).
|
||||
*/
|
||||
export const MAX_TRACKED_AGENTS = 500;
|
||||
|
||||
/**
|
||||
* Maximum activity entries to keep per agent.
|
||||
* Includes tool calls, status updates, progress reports.
|
||||
*/
|
||||
export const MAX_SUBAGENT_ACTIVITY_PER_AGENT = 100;
|
||||
|
||||
/**
|
||||
* Maximum tool results to keep per agent.
|
||||
* Prevents memory growth from long-running agents with many tool calls.
|
||||
*/
|
||||
export const MAX_TOOL_RESULTS_PER_AGENT = 200;
|
||||
|
||||
// ============================================================================
|
||||
// Session Tracking Limits
|
||||
// ============================================================================
|
||||
@@ -65,11 +43,6 @@ export const MAX_SSE_CLIENTS = 100;
|
||||
*/
|
||||
export const MAX_TODOS_PER_SESSION = 500;
|
||||
|
||||
/**
|
||||
* TTL for completed todo items before cleanup (1 hour).
|
||||
*/
|
||||
export const COMPLETED_TODO_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
// ============================================================================
|
||||
// Pending Tool Calls Limits
|
||||
// ============================================================================
|
||||
|
||||
@@ -171,7 +171,10 @@ export class FileStreamManager extends EventEmitter {
|
||||
}
|
||||
} catch (err) {
|
||||
const errorCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : 'UNKNOWN';
|
||||
console.warn(`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`, err instanceof Error ? err.message : String(err));
|
||||
console.warn(
|
||||
`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`,
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
return { success: false, error: 'File not found or not accessible' };
|
||||
}
|
||||
|
||||
@@ -379,9 +382,7 @@ export class FileStreamManager extends EventEmitter {
|
||||
}
|
||||
|
||||
// Resolve to absolute path
|
||||
let absolutePath = isAbsolute(expandedPath)
|
||||
? resolve(expandedPath)
|
||||
: resolve(workingDir, expandedPath);
|
||||
let absolutePath = isAbsolute(expandedPath) ? resolve(expandedPath) : resolve(workingDir, expandedPath);
|
||||
|
||||
// Resolve symlinks to prevent symlink attacks — validate the real target,
|
||||
// not the symlink itself. Fall back to resolved path if file doesn't exist yet.
|
||||
@@ -397,13 +398,7 @@ export class FileStreamManager extends EventEmitter {
|
||||
|
||||
// Check if the resolved path is within the working directory
|
||||
// or common log directories (/tmp intentionally excluded — world-writable)
|
||||
const allowedPaths = [
|
||||
normalizedWorkingDir,
|
||||
'/var/log',
|
||||
resolve(homedir(), '.local/share'),
|
||||
resolve(homedir(), '.cache'),
|
||||
resolve(homedir(), 'logs'),
|
||||
];
|
||||
const allowedPaths = [normalizedWorkingDir, '/var/log', resolve(homedir(), 'logs')];
|
||||
|
||||
const isAllowed = allowedPaths.some((allowed) => {
|
||||
const rel = relative(allowed, absolutePath);
|
||||
|
||||
@@ -27,9 +27,10 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
||||
// Falls back to empty object if stdin is unavailable or malformed.
|
||||
const curlCmd = (event: HookEventType) =>
|
||||
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
|
||||
`printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` +
|
||||
`curl -s -X POST "$CODEMAN_API_URL/api/hook-event" ` +
|
||||
`-H 'Content-Type: application/json' ` +
|
||||
`-d "{\\"event\\":\\"${event}\\",\\"sessionId\\":\\"$CODEMAN_SESSION_ID\\",\\"data\\":$HOOK_DATA}" ` +
|
||||
`--data @- ` +
|
||||
`2>/dev/null || true`;
|
||||
|
||||
return {
|
||||
|
||||
@@ -24,15 +24,7 @@ export interface ImageWatcherEvents {
|
||||
// ========== Constants ==========
|
||||
|
||||
/** Supported image file extensions (lowercase) */
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
'.svg',
|
||||
]);
|
||||
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
||||
|
||||
/** Time to wait for file writes to stabilize (ms) */
|
||||
const STABILITY_THRESHOLD_MS = 500;
|
||||
@@ -171,7 +163,12 @@ export class ImageWatcher extends EventEmitter {
|
||||
// Ignore common heavy directories for performance
|
||||
ignored: (path: string) => {
|
||||
// Skip node_modules, .git, and other heavy directories
|
||||
if (path.includes('/node_modules/') || path.includes('/.git/') || path.includes('/dist/') || path.includes('/.next/')) {
|
||||
if (
|
||||
path.includes('/node_modules/') ||
|
||||
path.includes('/.git/') ||
|
||||
path.includes('/dist/') ||
|
||||
path.includes('/.next/')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const ext = extname(path).toLowerCase();
|
||||
|
||||
@@ -23,7 +23,9 @@ let errorResetTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function trackError(): void {
|
||||
consecutiveErrors++;
|
||||
if (errorResetTimer) clearTimeout(errorResetTimer);
|
||||
errorResetTimer = setTimeout(() => { consecutiveErrors = 0; }, ERROR_RESET_MS);
|
||||
errorResetTimer = setTimeout(() => {
|
||||
consecutiveErrors = 0;
|
||||
}, ERROR_RESET_MS);
|
||||
|
||||
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
||||
console.error(`[FATAL] ${MAX_CONSECUTIVE_ERRORS} consecutive unhandled errors — exiting for systemd restart`);
|
||||
|
||||
@@ -7,7 +7,14 @@
|
||||
*/
|
||||
|
||||
import type { EventEmitter } from 'node:events';
|
||||
import type { ProcessStats, PersistedRespawnConfig, NiceConfig, ClaudeMode, SessionMode, OpenCodeConfig } from './types.js';
|
||||
import type {
|
||||
ProcessStats,
|
||||
PersistedRespawnConfig,
|
||||
NiceConfig,
|
||||
ClaudeMode,
|
||||
SessionMode,
|
||||
OpenCodeConfig,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Multiplexer session metadata.
|
||||
|
||||
@@ -19,10 +19,7 @@ import { Session } from './session.js';
|
||||
import type { TerminalMultiplexer } from './mux-interface.js';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
RESEARCH_AGENT_PROMPT,
|
||||
PLANNER_PROMPT,
|
||||
} from './prompts/index.js';
|
||||
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
|
||||
import { PlanTaskStatus, TddPhase } from './types.js';
|
||||
|
||||
// ============================================================================
|
||||
@@ -82,12 +79,6 @@ export interface ResearchResult {
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface PlannerResult {
|
||||
items: PlanItem[];
|
||||
gaps: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface DetailedPlanResult {
|
||||
success: boolean;
|
||||
items?: PlanItem[];
|
||||
@@ -170,7 +161,7 @@ export class PlanOrchestrator {
|
||||
mux: TerminalMultiplexer,
|
||||
workingDir: string = process.cwd(),
|
||||
outputDir?: string,
|
||||
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> },
|
||||
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> }
|
||||
) {
|
||||
this.mux = mux;
|
||||
this.workingDir = workingDir;
|
||||
@@ -193,7 +184,11 @@ export class PlanOrchestrator {
|
||||
}
|
||||
|
||||
const promptPath = join(agentDir, 'prompt.md');
|
||||
writeFileSync(promptPath, `# ${agentType} Agent Prompt\n\nGenerated: ${new Date().toISOString()}\nDuration: ${(durationMs / 1000).toFixed(1)}s\n\n## Task\n${this.taskDescription}\n\n## Prompt\n${prompt}\n`, 'utf-8');
|
||||
writeFileSync(
|
||||
promptPath,
|
||||
`# ${agentType} Agent Prompt\n\nGenerated: ${new Date().toISOString()}\nDuration: ${(durationMs / 1000).toFixed(1)}s\n\n## Task\n${this.taskDescription}\n\n## Prompt\n${prompt}\n`,
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const resultPath = join(agentDir, 'result.json');
|
||||
writeFileSync(resultPath, JSON.stringify(result, null, 2), 'utf-8');
|
||||
@@ -227,9 +222,9 @@ export class PlanOrchestrator {
|
||||
|
||||
private generateSummary(result: DetailedPlanResult): string {
|
||||
const items = result.items || [];
|
||||
const p0 = items.filter(i => i.priority === 'P0');
|
||||
const p1 = items.filter(i => i.priority === 'P1');
|
||||
const p2 = items.filter(i => i.priority === 'P2');
|
||||
const p0 = items.filter((i) => i.priority === 'P0');
|
||||
const p1 = items.filter((i) => i.priority === 'P1');
|
||||
const p2 = items.filter((i) => i.priority === 'P2');
|
||||
|
||||
let md = `# Plan Summary\n\n`;
|
||||
md += `Generated: ${new Date().toISOString()}\n`;
|
||||
@@ -392,10 +387,29 @@ export class PlanOrchestrator {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (this.cancelled) {
|
||||
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: 'Cancelled', durationMs: 0 };
|
||||
return {
|
||||
success: false,
|
||||
findings: {
|
||||
externalResources: [],
|
||||
codebasePatterns: [],
|
||||
technicalRecommendations: [],
|
||||
potentialChallenges: [],
|
||||
recommendedTools: [],
|
||||
},
|
||||
enrichedTaskDescription: taskDescription,
|
||||
error: 'Cancelled',
|
||||
durationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
onSubagent?.({ type: 'started', agentId, agentType: 'research', model: this.researchModel, status: 'running', detail: 'Researching...' });
|
||||
onSubagent?.({
|
||||
type: 'started',
|
||||
agentId,
|
||||
agentType: 'research',
|
||||
model: this.researchModel,
|
||||
status: 'running',
|
||||
detail: 'Researching...',
|
||||
});
|
||||
|
||||
const session = new Session({
|
||||
workingDir: this.workingDir,
|
||||
@@ -411,27 +425,72 @@ export class PlanOrchestrator {
|
||||
// Start progress interval before try block to ensure cleanup in finally
|
||||
const progressInterval = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
onSubagent?.({ type: 'progress', agentId, agentType: 'research', model: this.researchModel, status: 'running', detail: `${elapsed}s elapsed` });
|
||||
onSubagent?.({
|
||||
type: 'progress',
|
||||
agentId,
|
||||
agentType: 'research',
|
||||
model: this.researchModel,
|
||||
status: 'running',
|
||||
detail: `${elapsed}s elapsed`,
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
try {
|
||||
const { result: response } = await session.runPrompt(prompt, { model: this.researchModel });
|
||||
|
||||
this.runningSessions.delete(session);
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
// Extract JSON from response
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error: 'No JSON found', durationMs });
|
||||
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: 'No JSON in response', durationMs };
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'research',
|
||||
model: this.researchModel,
|
||||
status: 'failed',
|
||||
error: 'No JSON found',
|
||||
durationMs,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
findings: {
|
||||
externalResources: [],
|
||||
codebasePatterns: [],
|
||||
technicalRecommendations: [],
|
||||
potentialChallenges: [],
|
||||
recommendedTools: [],
|
||||
},
|
||||
enrichedTaskDescription: taskDescription,
|
||||
error: 'No JSON in response',
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = tryParseJSON(jsonMatch[0]);
|
||||
if (!parsed.success) {
|
||||
onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error: parsed.error, durationMs });
|
||||
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: parsed.error, durationMs };
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'research',
|
||||
model: this.researchModel,
|
||||
status: 'failed',
|
||||
error: parsed.error,
|
||||
durationMs,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
findings: {
|
||||
externalResources: [],
|
||||
codebasePatterns: [],
|
||||
technicalRecommendations: [],
|
||||
potentialChallenges: [],
|
||||
recommendedTools: [],
|
||||
},
|
||||
enrichedTaskDescription: taskDescription,
|
||||
error: parsed.error,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
const data = parsed.data as Record<string, unknown>;
|
||||
@@ -444,22 +503,52 @@ export class PlanOrchestrator {
|
||||
potentialChallenges: Array.isArray(data.potentialChallenges) ? data.potentialChallenges : [],
|
||||
recommendedTools: Array.isArray(data.recommendedTools) ? data.recommendedTools : [],
|
||||
},
|
||||
enrichedTaskDescription: typeof data.enrichedTaskDescription === 'string' ? data.enrichedTaskDescription : taskDescription,
|
||||
enrichedTaskDescription:
|
||||
typeof data.enrichedTaskDescription === 'string' ? data.enrichedTaskDescription : taskDescription,
|
||||
durationMs,
|
||||
};
|
||||
|
||||
this.saveAgentOutput('research', prompt, result, durationMs);
|
||||
onSubagent?.({ type: 'completed', agentId, agentType: 'research', model: this.researchModel, status: 'completed', durationMs });
|
||||
onSubagent?.({
|
||||
type: 'completed',
|
||||
agentId,
|
||||
agentType: 'research',
|
||||
model: this.researchModel,
|
||||
status: 'completed',
|
||||
durationMs,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.runningSessions.delete(session);
|
||||
const durationMs = Date.now() - startTime;
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error, durationMs });
|
||||
return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error, durationMs };
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'research',
|
||||
model: this.researchModel,
|
||||
status: 'failed',
|
||||
error,
|
||||
durationMs,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
findings: {
|
||||
externalResources: [],
|
||||
codebasePatterns: [],
|
||||
technicalRecommendations: [],
|
||||
potentialChallenges: [],
|
||||
recommendedTools: [],
|
||||
},
|
||||
enrichedTaskDescription: taskDescription,
|
||||
error,
|
||||
durationMs,
|
||||
};
|
||||
} finally {
|
||||
// Always clear the progress interval to prevent memory leaks
|
||||
// Always clean up session and progress interval — centralizing here
|
||||
// prevents the race where cancel() and catch both try to manage the set
|
||||
await session.stop().catch(() => {});
|
||||
this.runningSessions.delete(session);
|
||||
clearInterval(progressInterval);
|
||||
}
|
||||
}
|
||||
@@ -469,7 +558,13 @@ export class PlanOrchestrator {
|
||||
researchContext: string,
|
||||
onProgress?: ProgressCallback,
|
||||
onSubagent?: SubagentCallback
|
||||
): Promise<{ success: boolean; items?: PlanItem[]; gaps?: string[]; warnings?: string[]; error?: string }> {
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
items?: PlanItem[];
|
||||
gaps?: string[];
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
}> {
|
||||
const agentId = `planner-${Date.now()}`;
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -477,7 +572,14 @@ export class PlanOrchestrator {
|
||||
return { success: false, error: 'Cancelled' };
|
||||
}
|
||||
|
||||
onSubagent?.({ type: 'started', agentId, agentType: 'planner', model: this.plannerModel, status: 'running', detail: 'Generating plan...' });
|
||||
onSubagent?.({
|
||||
type: 'started',
|
||||
agentId,
|
||||
agentType: 'planner',
|
||||
model: this.plannerModel,
|
||||
status: 'running',
|
||||
detail: 'Generating plan...',
|
||||
});
|
||||
|
||||
const session = new Session({
|
||||
workingDir: this.workingDir,
|
||||
@@ -488,33 +590,55 @@ export class PlanOrchestrator {
|
||||
|
||||
this.runningSessions.add(session);
|
||||
|
||||
const prompt = PLANNER_PROMPT
|
||||
.replace('{TASK}', taskDescription)
|
||||
.replace('{RESEARCH_CONTEXT}', researchContext || '');
|
||||
const prompt = PLANNER_PROMPT.replace('{TASK}', taskDescription).replace(
|
||||
'{RESEARCH_CONTEXT}',
|
||||
researchContext || ''
|
||||
);
|
||||
|
||||
// Start progress interval before try block to ensure cleanup in finally
|
||||
const progressInterval = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
onSubagent?.({ type: 'progress', agentId, agentType: 'planner', model: this.plannerModel, status: 'running', detail: `${elapsed}s elapsed` });
|
||||
onSubagent?.({
|
||||
type: 'progress',
|
||||
agentId,
|
||||
agentType: 'planner',
|
||||
model: this.plannerModel,
|
||||
status: 'running',
|
||||
detail: `${elapsed}s elapsed`,
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
try {
|
||||
const { result: response } = await session.runPrompt(prompt, { model: this.plannerModel });
|
||||
|
||||
this.runningSessions.delete(session);
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
// Extract JSON from response
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error: 'No JSON found', durationMs });
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'planner',
|
||||
model: this.plannerModel,
|
||||
status: 'failed',
|
||||
error: 'No JSON found',
|
||||
durationMs,
|
||||
});
|
||||
return { success: false, error: 'No JSON in response' };
|
||||
}
|
||||
|
||||
const parsed = tryParseJSON(jsonMatch[0]);
|
||||
if (!parsed.success) {
|
||||
onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error: parsed.error, durationMs });
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'planner',
|
||||
model: this.plannerModel,
|
||||
status: 'failed',
|
||||
error: parsed.error,
|
||||
durationMs,
|
||||
});
|
||||
return { success: false, error: parsed.error };
|
||||
}
|
||||
|
||||
@@ -524,19 +648,37 @@ export class PlanOrchestrator {
|
||||
const warnings: string[] = Array.isArray(data.warnings) ? data.warnings : [];
|
||||
|
||||
this.saveAgentOutput('planner', prompt, { items, gaps, warnings }, durationMs);
|
||||
onSubagent?.({ type: 'completed', agentId, agentType: 'planner', model: this.plannerModel, status: 'completed', itemCount: items.length, durationMs });
|
||||
onSubagent?.({
|
||||
type: 'completed',
|
||||
agentId,
|
||||
agentType: 'planner',
|
||||
model: this.plannerModel,
|
||||
status: 'completed',
|
||||
itemCount: items.length,
|
||||
durationMs,
|
||||
});
|
||||
|
||||
onProgress?.('planning', `Generated ${items.length} tasks`);
|
||||
|
||||
return { success: true, items, gaps, warnings };
|
||||
} catch (err) {
|
||||
this.runningSessions.delete(session);
|
||||
const durationMs = Date.now() - startTime;
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error, durationMs });
|
||||
onSubagent?.({
|
||||
type: 'failed',
|
||||
agentId,
|
||||
agentType: 'planner',
|
||||
model: this.plannerModel,
|
||||
status: 'failed',
|
||||
error,
|
||||
durationMs,
|
||||
});
|
||||
return { success: false, error };
|
||||
} finally {
|
||||
// Always clear the progress interval to prevent memory leaks
|
||||
// Always clean up session and progress interval — centralizing here
|
||||
// prevents the race where cancel() and catch both try to manage the set
|
||||
await session.stop().catch(() => {});
|
||||
this.runningSessions.delete(session);
|
||||
clearInterval(progressInterval);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ export class RalphLoop extends EventEmitter {
|
||||
completion: (sessionId: string, phrase: string) => void;
|
||||
error: (sessionId: string, error: string) => void;
|
||||
stopped: (sessionId: string) => void;
|
||||
taskError: (sessionId: string, taskId: string, error: string) => void;
|
||||
} | null = null;
|
||||
|
||||
constructor(options: RalphLoopOptions = {}) {
|
||||
@@ -118,11 +119,15 @@ export class RalphLoop extends EventEmitter {
|
||||
stopped: (sessionId: string) => {
|
||||
this.handleSessionStopped(sessionId);
|
||||
},
|
||||
taskError: (sessionId: string, taskId: string, error: string) => {
|
||||
this.handleSessionTaskError(sessionId, taskId, error);
|
||||
},
|
||||
};
|
||||
|
||||
this.sessionManager.on('sessionCompletion', this.sessionEventHandlers.completion);
|
||||
this.sessionManager.on('sessionError', this.sessionEventHandlers.error);
|
||||
this.sessionManager.on('sessionStopped', this.sessionEventHandlers.stopped);
|
||||
this.sessionManager.on('sessionTaskError', this.sessionEventHandlers.taskError);
|
||||
}
|
||||
|
||||
/** Remove event listeners to prevent memory leaks */
|
||||
@@ -131,6 +136,7 @@ export class RalphLoop extends EventEmitter {
|
||||
this.sessionManager.off('sessionCompletion', this.sessionEventHandlers.completion);
|
||||
this.sessionManager.off('sessionError', this.sessionEventHandlers.error);
|
||||
this.sessionManager.off('sessionStopped', this.sessionEventHandlers.stopped);
|
||||
this.sessionManager.off('sessionTaskError', this.sessionEventHandlers.taskError);
|
||||
this.sessionEventHandlers = null;
|
||||
}
|
||||
}
|
||||
@@ -281,11 +287,11 @@ export class RalphLoop extends EventEmitter {
|
||||
private async tick(): Promise<void> {
|
||||
this.store.setRalphLoopState({ lastCheckAt: Date.now() });
|
||||
|
||||
// Run independent checks in parallel for better performance
|
||||
await Promise.all([
|
||||
this.checkTimeouts(),
|
||||
this.assignTasks(),
|
||||
]);
|
||||
// Run sequentially: timeouts first so timed-out tasks are cleaned up
|
||||
// before assignTasks() picks new work (prevents race where both
|
||||
// mutate the same task concurrently)
|
||||
await this.checkTimeouts();
|
||||
await this.assignTasks();
|
||||
|
||||
// Check if we should auto-generate tasks (depends on assignment results)
|
||||
if (this.autoGenerateTasks && this.shouldGenerateTasks()) {
|
||||
@@ -307,7 +313,11 @@ export class RalphLoop extends EventEmitter {
|
||||
break;
|
||||
}
|
||||
|
||||
await this.assignTaskToSession(task, session);
|
||||
try {
|
||||
await this.assignTaskToSession(task, session);
|
||||
} catch (err) {
|
||||
console.error(`[RalphLoop] Failed to assign task ${task.id} to session ${session.id}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,17 +413,24 @@ export class RalphLoop extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private handleSessionTaskError(_sessionId: string, taskId: string, error: string): void {
|
||||
const task = this.taskQueue.getTask(taskId);
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
|
||||
task.fail(error);
|
||||
this.taskQueue.updateTask(task);
|
||||
this.emit('taskFailed', task.id, error);
|
||||
}
|
||||
|
||||
private shouldGenerateTasks(): boolean {
|
||||
// Generate tasks if:
|
||||
// 1. No pending tasks
|
||||
// 2. Min duration not reached
|
||||
// 3. We have idle sessions
|
||||
const counts = this.taskQueue.getCount();
|
||||
return (
|
||||
counts.pending === 0 &&
|
||||
!this.isMinDurationReached() &&
|
||||
this.sessionManager.getIdleSessions().length > 0
|
||||
);
|
||||
return counts.pending === 0 && !this.isMinDurationReached() && this.sessionManager.getIdleSessions().length > 0;
|
||||
}
|
||||
|
||||
private async generateFollowUpTasks(): Promise<void> {
|
||||
@@ -492,7 +509,7 @@ export function getRalphLoop(options?: RalphLoopOptions): RalphLoop {
|
||||
}
|
||||
|
||||
/** Destroys the singleton instance. Use in tests or for cleanup. */
|
||||
export function destroyRalphLoop(): void {
|
||||
function destroyRalphLoop(): void {
|
||||
if (loopInstance) {
|
||||
loopInstance.destroy();
|
||||
loopInstance = null;
|
||||
|
||||
@@ -34,12 +34,7 @@ import {
|
||||
PlanTaskStatus,
|
||||
TddPhase,
|
||||
} from './types.js';
|
||||
import {
|
||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||
fuzzyPhraseMatch,
|
||||
todoContentHash,
|
||||
stringSimilarity,
|
||||
} from './utils/index.js';
|
||||
import { ANSI_ESCAPE_PATTERN_SIMPLE, fuzzyPhraseMatch, todoContentHash, stringSimilarity } from './utils/index.js';
|
||||
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
|
||||
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
|
||||
|
||||
@@ -151,8 +146,19 @@ const MAX_PLAN_HISTORY = 10;
|
||||
* P1-002: Configurable false positive prevention
|
||||
*/
|
||||
const COMMON_COMPLETION_PHRASES = new Set([
|
||||
'DONE', 'COMPLETE', 'FINISHED', 'OK', 'YES', 'TRUE', 'SUCCESS',
|
||||
'READY', 'COMPLETED', 'PASSED', 'END', 'STOP', 'EXIT',
|
||||
'DONE',
|
||||
'COMPLETE',
|
||||
'FINISHED',
|
||||
'OK',
|
||||
'YES',
|
||||
'TRUE',
|
||||
'SUCCESS',
|
||||
'READY',
|
||||
'COMPLETED',
|
||||
'PASSED',
|
||||
'END',
|
||||
'STOP',
|
||||
'EXIT',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -249,9 +255,9 @@ const TODO_PLAIN_CHECKMARK_PATTERN = /✔\s+(.+)/g;
|
||||
* Prevents false positives from tool invocations and Claude commentary
|
||||
*/
|
||||
const TODO_EXCLUDE_PATTERNS = [
|
||||
/^(?:Bash|Search|Read|Write|Glob|Grep|Edit|Task)\s*\(/i, // Tool invocations
|
||||
/^(?:I'll |Let me |Now I|First,|Task \d+:|Result:|Error:)/i, // Claude commentary
|
||||
/^\S+\([^)]+\)$/, // Generic function call pattern
|
||||
/^(?:Bash|Search|Read|Write|Glob|Grep|Edit|Task)\s*\(/i, // Tool invocations
|
||||
/^(?:I'll |Let me |Now I|First,|Task \d+:|Result:|Error:)/i, // Claude commentary
|
||||
/^\S+\([^)]+\)$/, // Generic function call pattern
|
||||
];
|
||||
|
||||
// ---------- Loop Status Patterns ----------
|
||||
@@ -313,7 +319,8 @@ const TODOWRITE_PATTERN = /TodoWrite|todo(?:s)?\s*(?:updated|written|saved)|Todo
|
||||
* Examples: "All 8 files have been created", "All tasks completed", "Everything is done"
|
||||
* Used to mark all tracked todos as complete at once
|
||||
*/
|
||||
const ALL_COMPLETE_PATTERN = /all\s+(?:\d+\s+)?(?:tasks?|files?|items?)\s+(?:have\s+been\s+|are\s+)?(?:completed?|done|finished|created)|completed?\s+all\s+(?:\d+\s+)?tasks?|all\s+done|everything\s+(?:is\s+)?(?:completed?|done)|finished\s+all\s+tasks?/i;
|
||||
const ALL_COMPLETE_PATTERN =
|
||||
/all\s+(?:\d+\s+)?(?:tasks?|files?|items?)\s+(?:have\s+been\s+|are\s+)?(?:completed?|done|finished|created)|completed?\s+all\s+(?:\d+\s+)?tasks?|all\s+done|everything\s+(?:is\s+)?(?:completed?|done)|finished\s+all\s+tasks?/i;
|
||||
|
||||
/**
|
||||
* Extracts count from "all N items" messages
|
||||
@@ -327,7 +334,8 @@ const ALL_COUNT_PATTERN = /all\s+(\d+)\s+(?:tasks?|files?|items?)/i;
|
||||
* Examples: "Task #5 is done", "marked as completed", "todo 3 finished"
|
||||
* Used to update specific todo items by number
|
||||
*/
|
||||
const TASK_DONE_PATTERN = /(?:task|item|todo)\s*(?:#?\d+|"\s*[^"]+\s*")?\s*(?:is\s+)?(?:done|completed?|finished)|(?:completed?|done|finished)\s+(?:task|item)\s*(?:#?\d+)?|marking\s+(?:.*?\s+)?(?:as\s+)?completed?|marked\s+(?:.*?\s+)?(?:as\s+)?completed?/i;
|
||||
const TASK_DONE_PATTERN =
|
||||
/(?:task|item|todo)\s*(?:#?\d+|"\s*[^"]+\s*")?\s*(?:is\s+)?(?:done|completed?|finished)|(?:completed?|done|finished)\s+(?:task|item)\s*(?:#?\d+)?|marking\s+(?:.*?\s+)?(?:as\s+)?completed?|marked\s+(?:.*?\s+)?(?:as\s+)?completed?/i;
|
||||
|
||||
// ---------- Utility Patterns ----------
|
||||
|
||||
@@ -411,48 +419,48 @@ const COMPLETION_INDICATOR_PATTERNS = [
|
||||
|
||||
/** P0 (Critical) priority patterns - highest severity issues */
|
||||
const P0_PRIORITY_PATTERNS = [
|
||||
/\bP0\b|\(P0\)|:?\s*P0\s*:/, // Explicit P0
|
||||
/\bCRITICAL\b/, // Critical keyword
|
||||
/\bBLOCKER\b/, // Blocker
|
||||
/\bURGENT\b/, // Urgent
|
||||
/\bSECURITY\b/, // Security issues
|
||||
/\bCRASH(?:ES|ING)?\b/, // Crash, crashes, crashing
|
||||
/\bBROKEN\b/, // Broken
|
||||
/\bDATA\s*LOSS\b/, // Data loss
|
||||
/\bPRODUCTION\s*(?:DOWN|ISSUE|BUG)\b/, // Production issues
|
||||
/\bHOTFIX\b/, // Hotfix
|
||||
/\bSEVERITY\s*1\b/, // Severity 1
|
||||
/\bP0\b|\(P0\)|:?\s*P0\s*:/, // Explicit P0
|
||||
/\bCRITICAL\b/, // Critical keyword
|
||||
/\bBLOCKER\b/, // Blocker
|
||||
/\bURGENT\b/, // Urgent
|
||||
/\bSECURITY\b/, // Security issues
|
||||
/\bCRASH(?:ES|ING)?\b/, // Crash, crashes, crashing
|
||||
/\bBROKEN\b/, // Broken
|
||||
/\bDATA\s*LOSS\b/, // Data loss
|
||||
/\bPRODUCTION\s*(?:DOWN|ISSUE|BUG)\b/, // Production issues
|
||||
/\bHOTFIX\b/, // Hotfix
|
||||
/\bSEVERITY\s*1\b/, // Severity 1
|
||||
];
|
||||
|
||||
/** P1 (High) priority patterns - important issues requiring attention */
|
||||
const P1_PRIORITY_PATTERNS = [
|
||||
/\bP1\b|\(P1\)|:?\s*P1\s*:/, // Explicit P1
|
||||
/\bHIGH\s*PRIORITY\b/, // High priority
|
||||
/\bIMPORTANT\b/, // Important
|
||||
/\bBUG\b/, // Bug
|
||||
/\bFIX\b/, // Fix (as task type)
|
||||
/\bERROR\b/, // Error
|
||||
/\bFAIL(?:S|ED|ING|URE)?\b/, // Fail variants
|
||||
/\bREGRESSION\b/, // Regression
|
||||
/\bMUST\s*(?:HAVE|FIX|DO)\b/, // Must have/fix/do
|
||||
/\bSEVERITY\s*2\b/, // Severity 2
|
||||
/\bREQUIRED\b/, // Required
|
||||
/\bP1\b|\(P1\)|:?\s*P1\s*:/, // Explicit P1
|
||||
/\bHIGH\s*PRIORITY\b/, // High priority
|
||||
/\bIMPORTANT\b/, // Important
|
||||
/\bBUG\b/, // Bug
|
||||
/\bFIX\b/, // Fix (as task type)
|
||||
/\bERROR\b/, // Error
|
||||
/\bFAIL(?:S|ED|ING|URE)?\b/, // Fail variants
|
||||
/\bREGRESSION\b/, // Regression
|
||||
/\bMUST\s*(?:HAVE|FIX|DO)\b/, // Must have/fix/do
|
||||
/\bSEVERITY\s*2\b/, // Severity 2
|
||||
/\bREQUIRED\b/, // Required
|
||||
];
|
||||
|
||||
/** P2 (Medium) priority patterns - lower priority improvements */
|
||||
const P2_PRIORITY_PATTERNS = [
|
||||
/\bP2\b|\(P2\)|:?\s*P2\s*:/, // Explicit P2
|
||||
/\bNICE\s*TO\s*HAVE\b/, // Nice to have
|
||||
/\bLOW\s*PRIORITY\b/, // Low priority
|
||||
/\bREFACTOR\b/, // Refactor
|
||||
/\bCLEANUP\b/, // Cleanup
|
||||
/\bIMPROVE(?:MENT)?\b/, // Improve/Improvement
|
||||
/\bOPTIMIZ(?:E|ATION)\b/, // Optimize/Optimization
|
||||
/\bCONSIDER\b/, // Consider
|
||||
/\bWOULD\s*BE\s*NICE\b/, // Would be nice
|
||||
/\bENHANCE(?:MENT)?\b/, // Enhance/Enhancement
|
||||
/\bTECH(?:NICAL)?\s*DEBT\b/, // Tech debt
|
||||
/\bDOCUMENT(?:ATION)?\b/, // Documentation
|
||||
/\bP2\b|\(P2\)|:?\s*P2\s*:/, // Explicit P2
|
||||
/\bNICE\s*TO\s*HAVE\b/, // Nice to have
|
||||
/\bLOW\s*PRIORITY\b/, // Low priority
|
||||
/\bREFACTOR\b/, // Refactor
|
||||
/\bCLEANUP\b/, // Cleanup
|
||||
/\bIMPROVE(?:MENT)?\b/, // Improve/Improvement
|
||||
/\bOPTIMIZ(?:E|ATION)\b/, // Optimize/Optimization
|
||||
/\bCONSIDER\b/, // Consider
|
||||
/\bWOULD\s*BE\s*NICE\b/, // Would be nice
|
||||
/\bENHANCE(?:MENT)?\b/, // Enhance/Enhancement
|
||||
/\bTECH(?:NICAL)?\s*DEBT\b/, // Tech debt
|
||||
/\bDOCUMENT(?:ATION)?\b/, // Documentation
|
||||
];
|
||||
|
||||
// ========== Event Types ==========
|
||||
@@ -950,7 +958,7 @@ export class RalphTracker extends EventEmitter {
|
||||
|
||||
const wasEnabled = this._loopState.enabled;
|
||||
this._loopState = createInitialRalphTrackerState();
|
||||
this._loopState.enabled = wasEnabled; // Keep enabled status
|
||||
this._loopState.enabled = wasEnabled; // Keep enabled status
|
||||
this._todos.clear();
|
||||
this._completionPhraseCount.clear();
|
||||
this._taskNumberToContent.clear();
|
||||
@@ -1241,7 +1249,7 @@ export class RalphTracker extends EventEmitter {
|
||||
|
||||
// Check if all todos are complete (adds 20 points)
|
||||
const todoArray = Array.from(this._todos.values());
|
||||
if (todoArray.length > 0 && todoArray.every(t => t.status === 'completed')) {
|
||||
if (todoArray.length > 0 && todoArray.every((t) => t.status === 'completed')) {
|
||||
signals.allTodosComplete = true;
|
||||
score += 20;
|
||||
}
|
||||
@@ -1262,10 +1270,12 @@ export class RalphTracker extends EventEmitter {
|
||||
if (context) {
|
||||
const lowerContext = context.toLowerCase();
|
||||
// Deduct points if phrase appears in prompt-like context
|
||||
if (lowerContext.includes('output:') ||
|
||||
lowerContext.includes('completion phrase') ||
|
||||
lowerContext.includes('output exactly') ||
|
||||
lowerContext.includes('when done')) {
|
||||
if (
|
||||
lowerContext.includes('output:') ||
|
||||
lowerContext.includes('completion phrase') ||
|
||||
lowerContext.includes('output exactly') ||
|
||||
lowerContext.includes('when done')
|
||||
) {
|
||||
signals.contextAppropriate = false;
|
||||
score -= 20;
|
||||
} else {
|
||||
@@ -1335,7 +1345,6 @@ export class RalphTracker extends EventEmitter {
|
||||
* Use this when the caller has already stripped ANSI to avoid redundant regex work.
|
||||
*/
|
||||
processCleanData(cleanData: string): void {
|
||||
|
||||
// If tracker is disabled, only check for patterns that should auto-enable it
|
||||
if (!this._loopState.enabled) {
|
||||
// Don't auto-enable if explicitly disabled by user setting
|
||||
@@ -1396,14 +1405,21 @@ export class RalphTracker extends EventEmitter {
|
||||
// substrings that any pattern could match are present in the data.
|
||||
// This avoids 12 regex tests on every PTY chunk (the common case).
|
||||
if (
|
||||
!data.includes('<') && // <promise>, TodoWrite
|
||||
!data.includes('ralph') && !data.includes('Ralph') &&
|
||||
!data.includes('Todo') && !data.includes('todo') &&
|
||||
!data.includes('Iteration') && !data.includes('[') &&
|
||||
!data.includes('\u2610') && !data.includes('\u2612') && // ☐ ☒
|
||||
!data.includes('<') && // <promise>, TodoWrite
|
||||
!data.includes('ralph') &&
|
||||
!data.includes('Ralph') &&
|
||||
!data.includes('Todo') &&
|
||||
!data.includes('todo') &&
|
||||
!data.includes('Iteration') &&
|
||||
!data.includes('[') &&
|
||||
!data.includes('\u2610') &&
|
||||
!data.includes('\u2612') && // ☐ ☒
|
||||
!data.includes('\u2714') && // ✔
|
||||
!data.includes('Loop') && !data.includes('complete') &&
|
||||
!data.includes('COMPLETE') && !data.includes('Done') && !data.includes('DONE')
|
||||
!data.includes('Loop') &&
|
||||
!data.includes('complete') &&
|
||||
!data.includes('COMPLETE') &&
|
||||
!data.includes('Done') &&
|
||||
!data.includes('DONE')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -1677,8 +1693,8 @@ export class RalphTracker extends EventEmitter {
|
||||
// Avoid false positives: don't trigger on prompt context
|
||||
const isNotInPromptContext = !line.includes('<promise>') && !line.includes('output:');
|
||||
// Also avoid triggering on "completion phrase is X" explanatory text
|
||||
const isNotExplanation = !line.toLowerCase().includes('completion phrase') &&
|
||||
!line.toLowerCase().includes('output exactly');
|
||||
const isNotExplanation =
|
||||
!line.toLowerCase().includes('completion phrase') && !line.toLowerCase().includes('output exactly');
|
||||
|
||||
if (isNotInPromptContext && isNotExplanation) {
|
||||
this.handleBareCompletionPhrase(expectedPhrase);
|
||||
@@ -1786,7 +1802,7 @@ export class RalphTracker extends EventEmitter {
|
||||
|
||||
if (matchedPhrase) {
|
||||
// Use the matched phrase (canonical) for tracking
|
||||
const canonicalCount = (this._completionPhraseCount.get(matchedPhrase) || 0);
|
||||
const canonicalCount = this._completionPhraseCount.get(matchedPhrase) || 0;
|
||||
// Require 2nd+ occurrence of canonical phrase OR explicitly active loop.
|
||||
// First occurrence (count=1) is the prompt echo — not actual completion.
|
||||
if (canonicalCount >= 2 || this._loopState.active) {
|
||||
@@ -1862,7 +1878,7 @@ export class RalphTracker extends EventEmitter {
|
||||
* @fires phraseValidationWarning - When a risky phrase is detected
|
||||
*/
|
||||
private validateCompletionPhrase(phrase: string): void {
|
||||
const normalized = phrase.toUpperCase().replace(/[\s_\-\.]+/g, '');
|
||||
const normalized = phrase.toUpperCase().replace(/[\s_\-.]+/g, '');
|
||||
|
||||
// Generate a suggested unique phrase
|
||||
const uniqueSuffix = Date.now().toString(36).slice(-4).toUpperCase();
|
||||
@@ -1870,7 +1886,9 @@ export class RalphTracker extends EventEmitter {
|
||||
|
||||
// Check for common phrases
|
||||
if (COMMON_COMPLETION_PHRASES.has(normalized)) {
|
||||
console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is very common and may cause false positives. Consider using: "${suggestedPhrase}"`);
|
||||
console.warn(
|
||||
`[RalphTracker] Warning: Completion phrase "${phrase}" is very common and may cause false positives. Consider using: "${suggestedPhrase}"`
|
||||
);
|
||||
this.emit('phraseValidationWarning', {
|
||||
phrase,
|
||||
reason: 'common',
|
||||
@@ -1881,7 +1899,9 @@ export class RalphTracker extends EventEmitter {
|
||||
|
||||
// Check for short phrases
|
||||
if (normalized.length < MIN_RECOMMENDED_PHRASE_LENGTH) {
|
||||
console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is too short (${normalized.length} chars). Consider using: "${suggestedPhrase}"`);
|
||||
console.warn(
|
||||
`[RalphTracker] Warning: Completion phrase "${phrase}" is too short (${normalized.length} chars). Consider using: "${suggestedPhrase}"`
|
||||
);
|
||||
this.emit('phraseValidationWarning', {
|
||||
phrase,
|
||||
reason: 'short',
|
||||
@@ -1892,7 +1912,9 @@ export class RalphTracker extends EventEmitter {
|
||||
|
||||
// Check for numeric-only phrases
|
||||
if (/^\d+$/.test(normalized)) {
|
||||
console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is numeric-only and may cause false positives. Consider using: "${suggestedPhrase}"`);
|
||||
console.warn(
|
||||
`[RalphTracker] Warning: Completion phrase "${phrase}" is numeric-only and may cause false positives. Consider using: "${suggestedPhrase}"`
|
||||
);
|
||||
this.emit('phraseValidationWarning', {
|
||||
phrase,
|
||||
reason: 'numeric',
|
||||
@@ -1971,14 +1993,16 @@ export class RalphTracker extends EventEmitter {
|
||||
if (currentIter !== this._lastObservedIteration) {
|
||||
this._lastIterationChangeTime = Date.now();
|
||||
this._lastObservedIteration = currentIter;
|
||||
this._iterationStallWarned = false; // Reset warning on iteration change
|
||||
this._iterationStallWarned = false; // Reset warning on iteration change
|
||||
|
||||
// P1-004: Reset circuit breaker on successful iteration progress
|
||||
// If we're making progress, the loop is healthy
|
||||
if (this._circuitBreaker.state === 'HALF_OPEN' ||
|
||||
this._circuitBreaker.consecutiveNoProgress > 0 ||
|
||||
this._circuitBreaker.consecutiveSameError > 0 ||
|
||||
this._circuitBreaker.consecutiveTestsFailure > 0) {
|
||||
if (
|
||||
this._circuitBreaker.state === 'HALF_OPEN' ||
|
||||
this._circuitBreaker.consecutiveNoProgress > 0 ||
|
||||
this._circuitBreaker.consecutiveSameError > 0 ||
|
||||
this._circuitBreaker.consecutiveTestsFailure > 0
|
||||
) {
|
||||
this._circuitBreaker.consecutiveNoProgress = 0;
|
||||
this._circuitBreaker.consecutiveSameError = 0;
|
||||
this._circuitBreaker.lastProgressIteration = currentIter;
|
||||
@@ -2106,7 +2130,7 @@ export class RalphTracker extends EventEmitter {
|
||||
const content = match[2].trim();
|
||||
|
||||
// Skip if content matches exclude patterns (tool invocations, commentary)
|
||||
const shouldExclude = TODO_EXCLUDE_PATTERNS.some(pattern => pattern.test(content));
|
||||
const shouldExclude = TODO_EXCLUDE_PATTERNS.some((pattern) => pattern.test(content));
|
||||
if (shouldExclude) continue;
|
||||
|
||||
// Skip if content is too short or looks like partial garbage
|
||||
@@ -2155,9 +2179,8 @@ export class RalphTracker extends EventEmitter {
|
||||
while ((match = TODO_TASK_STATUS_PATTERN.exec(line)) !== null) {
|
||||
const taskNum = parseInt(match[1], 10);
|
||||
const statusStr = match[2].trim();
|
||||
const status: RalphTodoStatus = statusStr === 'completed' ? 'completed'
|
||||
: statusStr === 'in progress' ? 'in_progress'
|
||||
: 'pending';
|
||||
const status: RalphTodoStatus =
|
||||
statusStr === 'completed' ? 'completed' : statusStr === 'in progress' ? 'in_progress' : 'pending';
|
||||
const content = this._taskNumberToContent.get(taskNum);
|
||||
if (content) {
|
||||
this.upsertTodo(content, status);
|
||||
@@ -2172,7 +2195,7 @@ export class RalphTracker extends EventEmitter {
|
||||
while ((match = TODO_PLAIN_CHECKMARK_PATTERN.exec(line)) !== null) {
|
||||
const content = match[1].trim();
|
||||
// Skip if content matches exclude patterns
|
||||
const shouldExclude = TODO_EXCLUDE_PATTERNS.some(pattern => pattern.test(content));
|
||||
const shouldExclude = TODO_EXCLUDE_PATTERNS.some((pattern) => pattern.test(content));
|
||||
if (shouldExclude) continue;
|
||||
if (content.length < 5) continue;
|
||||
// Skip status/created/updated prefixed content (already handled above)
|
||||
@@ -2204,17 +2227,17 @@ export class RalphTracker extends EventEmitter {
|
||||
switch (icon) {
|
||||
case '✓':
|
||||
case '✅':
|
||||
case '☒': // Claude Code checked checkbox
|
||||
case '◉': // Filled circle (completed)
|
||||
case '●': // Solid circle (completed)
|
||||
case '☒': // Claude Code checked checkbox
|
||||
case '◉': // Filled circle (completed)
|
||||
case '●': // Solid circle (completed)
|
||||
return 'completed';
|
||||
case '◐': // Half-filled circle (in progress)
|
||||
case '◐': // Half-filled circle (in progress)
|
||||
case '⏳':
|
||||
case '⌛':
|
||||
case '🔄':
|
||||
return 'in_progress';
|
||||
case '☐': // Claude Code empty checkbox
|
||||
case '○': // Empty circle
|
||||
case '☐': // Claude Code empty checkbox
|
||||
case '○': // Empty circle
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
@@ -2280,10 +2303,10 @@ export class RalphTracker extends EventEmitter {
|
||||
|
||||
// Clean content: remove ANSI codes, collapse whitespace, trim
|
||||
const cleanContent = content
|
||||
.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '') // Remove ANSI escape codes
|
||||
.replace(/\s+/g, ' ') // Collapse whitespace
|
||||
.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '') // Remove ANSI escape codes
|
||||
.replace(/\s+/g, ' ') // Collapse whitespace
|
||||
.trim();
|
||||
if (cleanContent.length < 5) return; // Skip very short content
|
||||
if (cleanContent.length < 5) return; // Skip very short content
|
||||
|
||||
// Parse priority from content
|
||||
const priority = this.parsePriority(cleanContent);
|
||||
@@ -2402,8 +2425,8 @@ export class RalphTracker extends EventEmitter {
|
||||
private normalizeTodoContent(content: string): string {
|
||||
if (!content) return '';
|
||||
return content
|
||||
.replace(/\s+/g, ' ') // Collapse whitespace
|
||||
.replace(/[^a-zA-Z0-9\s.,!?'"-]/g, '') // Remove special chars (keep punctuation)
|
||||
.replace(/\s+/g, ' ') // Collapse whitespace
|
||||
.replace(/[^a-zA-Z0-9\s.,!?'"-]/g, '') // Remove special chars (keep punctuation)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
@@ -2507,7 +2530,7 @@ export class RalphTracker extends EventEmitter {
|
||||
if (normalized.length < 30) {
|
||||
threshold = 0.95; // Very strict for short strings
|
||||
} else if (normalized.length < 60) {
|
||||
threshold = 0.90; // Strict for medium strings
|
||||
threshold = 0.9; // Strict for medium strings
|
||||
} else {
|
||||
threshold = TODO_SIMILARITY_THRESHOLD; // 0.85 for longer strings
|
||||
}
|
||||
@@ -2563,14 +2586,7 @@ export class RalphTracker extends EventEmitter {
|
||||
];
|
||||
|
||||
// Moderate: Bugs, features, enhancements
|
||||
const moderatePatterns = [
|
||||
/\bbug\b/,
|
||||
/\bfeature\b/,
|
||||
/\benhance(?:ment)?\b/,
|
||||
/\bimplement\b/,
|
||||
/\badd\b/,
|
||||
/\bfix\b/,
|
||||
];
|
||||
const moderatePatterns = [/\bbug\b/, /\bfeature\b/, /\benhance(?:ment)?\b/, /\bimplement\b/, /\badd\b/, /\bfix\b/];
|
||||
|
||||
for (const pattern of complexPatterns) {
|
||||
if (pattern.test(lower)) return 'complex';
|
||||
@@ -2609,10 +2625,10 @@ export class RalphTracker extends EventEmitter {
|
||||
|
||||
// Default estimates (in ms) based on typical task durations
|
||||
const defaults = {
|
||||
trivial: 1 * 60 * 1000, // 1 minute
|
||||
simple: 3 * 60 * 1000, // 3 minutes
|
||||
moderate: 10 * 60 * 1000, // 10 minutes
|
||||
complex: 30 * 60 * 1000, // 30 minutes
|
||||
trivial: 1 * 60 * 1000, // 1 minute
|
||||
simple: 3 * 60 * 1000, // 3 minutes
|
||||
moderate: 10 * 60 * 1000, // 10 minutes
|
||||
complex: 30 * 60 * 1000, // 30 minutes
|
||||
};
|
||||
return defaults[complexity];
|
||||
}
|
||||
@@ -2671,9 +2687,9 @@ export class RalphTracker extends EventEmitter {
|
||||
public getTodoProgress(): RalphTodoProgress {
|
||||
const todos = Array.from(this._todos.values());
|
||||
const total = todos.length;
|
||||
const completed = todos.filter(t => t.status === 'completed').length;
|
||||
const inProgress = todos.filter(t => t.status === 'in_progress').length;
|
||||
const pending = todos.filter(t => t.status === 'pending').length;
|
||||
const completed = todos.filter((t) => t.status === 'completed').length;
|
||||
const inProgress = todos.filter((t) => t.status === 'in_progress').length;
|
||||
const pending = todos.filter((t) => t.status === 'pending').length;
|
||||
|
||||
const percentComplete = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
@@ -2983,7 +2999,7 @@ export class RalphTracker extends EventEmitter {
|
||||
// Ensure enabled flag exists (backwards compatibility)
|
||||
this._loopState = {
|
||||
...loopState,
|
||||
enabled: loopState.enabled ?? false, // Override after spread for backwards compat
|
||||
enabled: loopState.enabled ?? false, // Override after spread for backwards compat
|
||||
};
|
||||
this._todos.clear();
|
||||
for (const todo of todos) {
|
||||
@@ -3068,7 +3084,9 @@ export class RalphTracker extends EventEmitter {
|
||||
if (!Number.isNaN(value) && value >= 0) {
|
||||
block.tasksCompletedThisLoop = value;
|
||||
} else {
|
||||
parseErrors.push(`Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer`);
|
||||
parseErrors.push(
|
||||
`Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer`
|
||||
);
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
@@ -3104,7 +3122,9 @@ export class RalphTracker extends EventEmitter {
|
||||
if (['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(value)) {
|
||||
block.workType = value as RalphWorkType;
|
||||
} else {
|
||||
parseErrors.push(`Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`);
|
||||
parseErrors.push(
|
||||
`Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`
|
||||
);
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
@@ -3126,7 +3146,7 @@ export class RalphTracker extends EventEmitter {
|
||||
// Track unknown fields for debugging (only if looks like a field)
|
||||
if (!matched && trimmedLine.includes(':')) {
|
||||
const fieldName = trimmedLine.split(':')[0].trim().toUpperCase();
|
||||
if (fieldName && !['#', '//'].some(c => fieldName.startsWith(c))) {
|
||||
if (fieldName && !['#', '//'].some((c) => fieldName.startsWith(c))) {
|
||||
unknownFields.push(fieldName);
|
||||
}
|
||||
}
|
||||
@@ -3221,11 +3241,7 @@ export class RalphTracker extends EventEmitter {
|
||||
* @param status - Overall status from RALPH_STATUS
|
||||
* @fires circuitBreakerUpdate - If state changes
|
||||
*/
|
||||
private updateCircuitBreaker(
|
||||
hasProgress: boolean,
|
||||
testsStatus: RalphTestsStatus,
|
||||
status: RalphStatusValue
|
||||
): void {
|
||||
private updateCircuitBreaker(hasProgress: boolean, testsStatus: RalphTestsStatus, status: RalphStatusValue): void {
|
||||
const prevState = this._circuitBreaker.state;
|
||||
|
||||
if (hasProgress) {
|
||||
@@ -3319,7 +3335,11 @@ export class RalphTracker extends EventEmitter {
|
||||
/**
|
||||
* Get cumulative stats from status blocks.
|
||||
*/
|
||||
get cumulativeStats(): { filesModified: number; tasksCompleted: number; completionIndicators: number } {
|
||||
get cumulativeStats(): {
|
||||
filesModified: number;
|
||||
tasksCompleted: number;
|
||||
completionIndicators: number;
|
||||
} {
|
||||
return {
|
||||
filesModified: this._totalFilesModified,
|
||||
tasksCompleted: this._totalTasksCompleted,
|
||||
@@ -3456,7 +3476,7 @@ export class RalphTracker extends EventEmitter {
|
||||
const tasksHeaderPattern = /^##\s*Tasks/i;
|
||||
|
||||
// Pattern for todo items
|
||||
const todoPattern = /^-\s*\[([ x\-])\]\s*(.+)$/;
|
||||
const todoPattern = /^-\s*\[([ x-])\]\s*(.+)$/;
|
||||
|
||||
let inCompletedSection = false;
|
||||
|
||||
@@ -3504,7 +3524,7 @@ export class RalphTracker extends EventEmitter {
|
||||
}
|
||||
|
||||
// Parse priority from content if not in a priority section
|
||||
const parsedPriority = inCompletedSection ? null : (currentPriority || this.parsePriority(content));
|
||||
const parsedPriority = inCompletedSection ? null : currentPriority || this.parsePriority(content);
|
||||
|
||||
const id = this.generateTodoId(content);
|
||||
newTodos.push({
|
||||
@@ -3535,17 +3555,19 @@ export class RalphTracker extends EventEmitter {
|
||||
* Initialize plan tasks from generated plan items.
|
||||
* Called when wizard generates a new plan.
|
||||
*/
|
||||
initializePlanTasks(items: Array<{
|
||||
id?: string;
|
||||
content: string;
|
||||
priority?: 'P0' | 'P1' | 'P2' | null;
|
||||
verificationCriteria?: string;
|
||||
testCommand?: string;
|
||||
dependencies?: string[];
|
||||
tddPhase?: TddPhase;
|
||||
pairedWith?: string;
|
||||
complexity?: 'low' | 'medium' | 'high';
|
||||
}>): void {
|
||||
initializePlanTasks(
|
||||
items: Array<{
|
||||
id?: string;
|
||||
content: string;
|
||||
priority?: 'P0' | 'P1' | 'P2' | null;
|
||||
verificationCriteria?: string;
|
||||
testCommand?: string;
|
||||
dependencies?: string[];
|
||||
tddPhase?: TddPhase;
|
||||
pairedWith?: string;
|
||||
complexity?: 'low' | 'medium' | 'high';
|
||||
}>
|
||||
): void {
|
||||
// Save current plan to history before replacing
|
||||
if (this._planTasks.size > 0) {
|
||||
this._savePlanToHistory('Plan replaced with new generation');
|
||||
@@ -3580,11 +3602,14 @@ export class RalphTracker extends EventEmitter {
|
||||
/**
|
||||
* Update a specific plan task's status, attempts, or error.
|
||||
*/
|
||||
updatePlanTask(taskId: string, update: {
|
||||
status?: PlanTaskStatus;
|
||||
error?: string;
|
||||
incrementAttempts?: boolean;
|
||||
}): { success: boolean; task?: EnhancedPlanTask; error?: string } {
|
||||
updatePlanTask(
|
||||
taskId: string,
|
||||
update: {
|
||||
status?: PlanTaskStatus;
|
||||
error?: string;
|
||||
incrementAttempts?: boolean;
|
||||
}
|
||||
): { success: boolean; task?: EnhancedPlanTask; error?: string } {
|
||||
const task = this._planTasks.get(taskId);
|
||||
if (!task) {
|
||||
return { success: false, error: 'Task not found' };
|
||||
@@ -3635,7 +3660,7 @@ export class RalphTracker extends EventEmitter {
|
||||
for (const [_, task] of this._planTasks) {
|
||||
if (task.dependencies.includes(completedTaskId)) {
|
||||
// Check if all dependencies are now complete
|
||||
const allDepsComplete = task.dependencies.every(depId => {
|
||||
const allDepsComplete = task.dependencies.every((depId) => {
|
||||
const dep = this._planTasks.get(depId);
|
||||
return dep && dep.status === 'completed';
|
||||
});
|
||||
@@ -3653,8 +3678,7 @@ export class RalphTracker extends EventEmitter {
|
||||
*/
|
||||
private _checkForCheckpoint(): void {
|
||||
const currentIteration = this._loopState.cycleCount;
|
||||
if (this._checkpointIterations.includes(currentIteration) &&
|
||||
currentIteration > this._lastCheckpointIteration) {
|
||||
if (this._checkpointIterations.includes(currentIteration) && currentIteration > this._lastCheckpointIteration) {
|
||||
this._lastCheckpointIteration = currentIteration;
|
||||
const checkpoint = this.generateCheckpointReview();
|
||||
this.emit('planCheckpoint', checkpoint);
|
||||
@@ -3669,17 +3693,17 @@ export class RalphTracker extends EventEmitter {
|
||||
|
||||
const summary = {
|
||||
total: tasks.length,
|
||||
completed: tasks.filter(t => t.status === 'completed').length,
|
||||
failed: tasks.filter(t => t.status === 'failed').length,
|
||||
blocked: tasks.filter(t => t.status === 'blocked').length,
|
||||
pending: tasks.filter(t => t.status === 'pending').length,
|
||||
inProgress: tasks.filter(t => t.status === 'in_progress').length,
|
||||
completed: tasks.filter((t) => t.status === 'completed').length,
|
||||
failed: tasks.filter((t) => t.status === 'failed').length,
|
||||
blocked: tasks.filter((t) => t.status === 'blocked').length,
|
||||
pending: tasks.filter((t) => t.status === 'pending').length,
|
||||
inProgress: tasks.filter((t) => t.status === 'in_progress').length,
|
||||
};
|
||||
|
||||
// Find stuck tasks (3+ attempts or blocked)
|
||||
const stuckTasks = tasks
|
||||
.filter(t => t.attempts >= 3 || t.status === 'blocked')
|
||||
.map(t => ({
|
||||
.filter((t) => t.attempts >= 3 || t.status === 'blocked')
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
content: t.content,
|
||||
attempts: t.attempts,
|
||||
@@ -3697,9 +3721,7 @@ export class RalphTracker extends EventEmitter {
|
||||
recommendations.push('More tasks have failed than completed. Review approach and consider plan adjustment.');
|
||||
}
|
||||
|
||||
const progressPercent = summary.total > 0
|
||||
? Math.round((summary.completed / summary.total) * 100)
|
||||
: 0;
|
||||
const progressPercent = summary.total > 0 ? Math.round((summary.completed / summary.total) * 100) : 0;
|
||||
if (progressPercent < 20 && this._loopState.cycleCount > 10) {
|
||||
recommendations.push('Progress is slow. Consider simplifying tasks or reviewing dependencies.');
|
||||
}
|
||||
@@ -3749,7 +3771,7 @@ export class RalphTracker extends EventEmitter {
|
||||
summary: string;
|
||||
stats: { total: number; completed: number; failed: number };
|
||||
}> {
|
||||
return this._planHistory.map(h => {
|
||||
return this._planHistory.map((h) => {
|
||||
const tasks = Array.from(h.tasks.values());
|
||||
return {
|
||||
version: h.version,
|
||||
@@ -3757,8 +3779,8 @@ export class RalphTracker extends EventEmitter {
|
||||
summary: h.summary,
|
||||
stats: {
|
||||
total: tasks.length,
|
||||
completed: tasks.filter(t => t.status === 'completed').length,
|
||||
failed: tasks.filter(t => t.status === 'failed').length,
|
||||
completed: tasks.filter((t) => t.status === 'completed').length,
|
||||
failed: tasks.filter((t) => t.status === 'failed').length,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -3767,8 +3789,12 @@ export class RalphTracker extends EventEmitter {
|
||||
/**
|
||||
* Rollback to a previous plan version.
|
||||
*/
|
||||
rollbackToVersion(version: number): { success: boolean; plan?: EnhancedPlanTask[]; error?: string } {
|
||||
const historyEntry = this._planHistory.find(h => h.version === version);
|
||||
rollbackToVersion(version: number): {
|
||||
success: boolean;
|
||||
plan?: EnhancedPlanTask[];
|
||||
error?: string;
|
||||
} {
|
||||
const historyEntry = this._planHistory.find((h) => h.version === version);
|
||||
if (!historyEntry) {
|
||||
return { success: false, error: `Version ${version} not found in history` };
|
||||
}
|
||||
@@ -3807,7 +3833,7 @@ export class RalphTracker extends EventEmitter {
|
||||
// Generate unique ID
|
||||
const existingIds = Array.from(this._planTasks.keys());
|
||||
const prefix = task.priority || 'P1';
|
||||
let counter = existingIds.filter(id => id.startsWith(prefix)).length + 1;
|
||||
let counter = existingIds.filter((id) => id.startsWith(prefix)).length + 1;
|
||||
let id = `${prefix}-${String(counter).padStart(3, '0')}`;
|
||||
while (this._planTasks.has(id)) {
|
||||
counter++;
|
||||
@@ -3850,8 +3876,7 @@ export class RalphTracker extends EventEmitter {
|
||||
*/
|
||||
isCheckpointDue(): boolean {
|
||||
const currentIteration = this._loopState.cycleCount;
|
||||
return this._checkpointIterations.includes(currentIteration) &&
|
||||
currentIteration > this._lastCheckpointIteration;
|
||||
return this._checkpointIterations.includes(currentIteration) && currentIteration > this._lastCheckpointIteration;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,15 +41,8 @@ import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-
|
||||
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
|
||||
import type { TeamWatcher } from './team-watcher.js';
|
||||
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
||||
import {
|
||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||
TOKEN_PATTERN,
|
||||
assertNever,
|
||||
} from './utils/index.js';
|
||||
import {
|
||||
MAX_RESPAWN_BUFFER_SIZE,
|
||||
TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE,
|
||||
} from './config/buffer-limits.js';
|
||||
import { ANSI_ESCAPE_PATTERN_SIMPLE, TOKEN_PATTERN, assertNever } from './utils/index.js';
|
||||
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
|
||||
import type {
|
||||
RespawnCycleMetrics,
|
||||
RespawnAggregateMetrics,
|
||||
@@ -542,45 +535,45 @@ export interface RespawnEvents {
|
||||
|
||||
/** Default configuration values */
|
||||
const DEFAULT_CONFIG: RespawnConfig = {
|
||||
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback)
|
||||
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback)
|
||||
updatePrompt: 'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.',
|
||||
interStepDelayMs: 1000, // 1 second between steps
|
||||
interStepDelayMs: 1000, // 1 second between steps
|
||||
enabled: true,
|
||||
sendClear: true, // send /clear after update prompt
|
||||
sendInit: true, // send /init after /clear
|
||||
completionConfirmMs: 10000, // 10 seconds of silence after completion message
|
||||
noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all
|
||||
autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions)
|
||||
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
|
||||
aiIdleCheckEnabled: true, // use AI to confirm idle state
|
||||
sendClear: true, // send /clear after update prompt
|
||||
sendInit: true, // send /init after /clear
|
||||
completionConfirmMs: 10000, // 10 seconds of silence after completion message
|
||||
noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all
|
||||
autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions)
|
||||
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
|
||||
aiIdleCheckEnabled: true, // use AI to confirm idle state
|
||||
aiIdleCheckModel: 'claude-opus-4-5-20251101',
|
||||
aiIdleCheckMaxContext: 16000, // ~4k tokens
|
||||
aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow)
|
||||
aiIdleCheckMaxContext: 16000, // ~4k tokens
|
||||
aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow)
|
||||
aiIdleCheckCooldownMs: 180000, // 3 minutes after WORKING verdict
|
||||
aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept
|
||||
aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept
|
||||
aiPlanCheckModel: 'claude-opus-4-5-20251101',
|
||||
aiPlanCheckMaxContext: 8000, // ~2k tokens (plan mode UI is compact)
|
||||
aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow)
|
||||
aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE
|
||||
stuckStateDetectionEnabled: true, // detect stuck states
|
||||
stuckStateWarningMs: 300000, // 5 minutes warning threshold
|
||||
stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold
|
||||
maxStuckRecoveries: 3, // max recovery attempts
|
||||
aiPlanCheckMaxContext: 8000, // ~2k tokens (plan mode UI is compact)
|
||||
aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow)
|
||||
aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE
|
||||
stuckStateDetectionEnabled: true, // detect stuck states
|
||||
stuckStateWarningMs: 300000, // 5 minutes warning threshold
|
||||
stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold
|
||||
maxStuckRecoveries: 3, // max recovery attempts
|
||||
// P2-001: Adaptive timing
|
||||
adaptiveTimingEnabled: true, // Use adaptive timing based on historical patterns
|
||||
adaptiveMinConfirmMs: 5000, // Minimum 5 seconds
|
||||
adaptiveMaxConfirmMs: 30000, // Maximum 30 seconds
|
||||
adaptiveTimingEnabled: true, // Use adaptive timing based on historical patterns
|
||||
adaptiveMinConfirmMs: 5000, // Minimum 5 seconds
|
||||
adaptiveMaxConfirmMs: 30000, // Maximum 30 seconds
|
||||
// P2-002: Skip-clear optimization
|
||||
skipClearWhenLowContext: true, // Skip /clear when token count is low
|
||||
skipClearThresholdPercent: 30, // Skip if below 30% of max context
|
||||
skipClearWhenLowContext: true, // Skip /clear when token count is low
|
||||
skipClearThresholdPercent: 30, // Skip if below 30% of max context
|
||||
// P2-004: Cycle metrics
|
||||
trackCycleMetrics: true, // Track and persist cycle metrics
|
||||
trackCycleMetrics: true, // Track and persist cycle metrics
|
||||
// P2-001: Confidence scoring
|
||||
minIdleConfidence: 65, // Minimum confidence to trigger idle (0-100)
|
||||
confidenceWeightCompletion: 40, // Weight for completion message
|
||||
confidenceWeightSilence: 25, // Weight for output silence
|
||||
confidenceWeightTokens: 20, // Weight for token stability
|
||||
confidenceWeightNoWorking: 15, // Weight for working pattern absence
|
||||
minIdleConfidence: 65, // Minimum confidence to trigger idle (0-100)
|
||||
confidenceWeightCompletion: 40, // Weight for completion message
|
||||
confidenceWeightSilence: 25, // Weight for output silence
|
||||
confidenceWeightTokens: 20, // Weight for token stability
|
||||
confidenceWeightNoWorking: 15, // Weight for working pattern absence
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -648,9 +641,6 @@ export class RespawnController extends EventEmitter {
|
||||
/** Current state machine state */
|
||||
private _state: RespawnState = 'stopped';
|
||||
|
||||
/** Timer for idle detection timeout */
|
||||
private idleTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/** Timer for step delays */
|
||||
private stepTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -663,6 +653,9 @@ export class RespawnController extends EventEmitter {
|
||||
/** Timer for periodic detection status updates */
|
||||
private detectionUpdateTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/** Cached key fields from last emitted detection status (for dedup) */
|
||||
private lastEmittedDetectionKey: string = '';
|
||||
|
||||
/** Timer for auto-accepting plan mode prompts */
|
||||
private autoAcceptTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -737,7 +730,8 @@ export class RespawnController extends EventEmitter {
|
||||
// ========== Timer Tracking for UI Countdown Display ==========
|
||||
|
||||
/** Active timers being tracked for UI display */
|
||||
private activeTimers: Map<string, { name: string; startedAt: number; durationMs: number; endsAt: number }> = new Map();
|
||||
private activeTimers: Map<string, { name: string; startedAt: number; durationMs: number; endsAt: number }> =
|
||||
new Map();
|
||||
|
||||
/** Recent action log entries (for UI display, max 20) */
|
||||
private recentActions: ActionLogEntry[] = [];
|
||||
@@ -818,9 +812,9 @@ export class RespawnController extends EventEmitter {
|
||||
* Used as secondary signals, not primary detection.
|
||||
*/
|
||||
private readonly PROMPT_PATTERNS = [
|
||||
'❯', // Standard prompt
|
||||
'\u276f', // Unicode variant
|
||||
'⏵', // Claude Code prompt variant
|
||||
'❯', // Standard prompt
|
||||
'\u276f', // Unicode variant
|
||||
'⏵', // Claude Code prompt variant
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -829,15 +823,55 @@ export class RespawnController extends EventEmitter {
|
||||
* Note: ✻ and ✽ removed - they appear in completion messages too.
|
||||
*/
|
||||
private readonly WORKING_PATTERNS = [
|
||||
'Thinking', 'Writing', 'Reading', 'Running', 'Searching',
|
||||
'Editing', 'Creating', 'Deleting', 'Analyzing', 'Executing',
|
||||
'Synthesizing', 'Brewing', // Claude's processing indicators
|
||||
'Compiling', 'Building', 'Installing', 'Fetching', 'Downloading',
|
||||
'Processing', 'Generating', 'Loading', 'Starting', 'Updating',
|
||||
'Checking', 'Validating', 'Testing', 'Formatting', 'Linting',
|
||||
'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', // Spinner chars
|
||||
'◐', '◓', '◑', '◒', // Alternative spinners
|
||||
'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷', // Braille spinners
|
||||
'Thinking',
|
||||
'Writing',
|
||||
'Reading',
|
||||
'Running',
|
||||
'Searching',
|
||||
'Editing',
|
||||
'Creating',
|
||||
'Deleting',
|
||||
'Analyzing',
|
||||
'Executing',
|
||||
'Synthesizing',
|
||||
'Brewing', // Claude's processing indicators
|
||||
'Compiling',
|
||||
'Building',
|
||||
'Installing',
|
||||
'Fetching',
|
||||
'Downloading',
|
||||
'Processing',
|
||||
'Generating',
|
||||
'Loading',
|
||||
'Starting',
|
||||
'Updating',
|
||||
'Checking',
|
||||
'Validating',
|
||||
'Testing',
|
||||
'Formatting',
|
||||
'Linting',
|
||||
'⠋',
|
||||
'⠙',
|
||||
'⠹',
|
||||
'⠸',
|
||||
'⠼',
|
||||
'⠴',
|
||||
'⠦',
|
||||
'⠧',
|
||||
'⠇',
|
||||
'⠏', // Spinner chars
|
||||
'◐',
|
||||
'◓',
|
||||
'◑',
|
||||
'◒', // Alternative spinners
|
||||
'⣾',
|
||||
'⣽',
|
||||
'⣻',
|
||||
'⢿',
|
||||
'⡿',
|
||||
'⣟',
|
||||
'⣯',
|
||||
'⣷', // Braille spinners
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -1165,10 +1199,18 @@ export class RespawnController extends EventEmitter {
|
||||
private startDetectionUpdates(): void {
|
||||
this.stopDetectionUpdates();
|
||||
if (this._state === 'stopped') return;
|
||||
this.lastEmittedDetectionKey = '';
|
||||
this.detectionUpdateTimer = setInterval(() => {
|
||||
try {
|
||||
if (this._state !== 'stopped') {
|
||||
this.emit('detectionUpdate', this.getDetectionStatus());
|
||||
const status = this.getDetectionStatus();
|
||||
// Only emit when status meaningfully changed (confidence, state text, or timer values)
|
||||
// to avoid broadcasting identical data every 2s for stable/idle sessions.
|
||||
const key = `${status.confidenceLevel}|${status.statusText}|${this._state}`;
|
||||
if (key !== this.lastEmittedDetectionKey) {
|
||||
this.lastEmittedDetectionKey = key;
|
||||
this.emit('detectionUpdate', status);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[RespawnController] Error in detectionUpdateTimer:`, err);
|
||||
@@ -1200,7 +1242,7 @@ export class RespawnController extends EventEmitter {
|
||||
const prevState = this._state;
|
||||
this._state = newState;
|
||||
this.stateEnteredAt = Date.now();
|
||||
this.stuckStateWarned = false; // Reset warning for new state
|
||||
this.stuckStateWarned = false; // Reset warning for new state
|
||||
this.log(`State: ${prevState} → ${newState}`);
|
||||
this.logAction('state', `${prevState} → ${newState}`);
|
||||
this.emit('stateChanged', newState, prevState);
|
||||
@@ -1471,7 +1513,6 @@ export class RespawnController extends EventEmitter {
|
||||
this.elicitationDetected = false; // Clear on new work cycle
|
||||
this.resetHookState(); // Clear hook signals on new work
|
||||
this.lastWorkingPatternTime = now;
|
||||
this.clearIdleTimer();
|
||||
|
||||
// Cancel hook confirmation timer if running
|
||||
this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimer, 'working patterns detected');
|
||||
@@ -1518,7 +1559,9 @@ export class RespawnController extends EventEmitter {
|
||||
this.setState('watching');
|
||||
} else {
|
||||
// Real content (not just escape codes or single chars) - cancel confirmation
|
||||
this.log(`Substantial output during confirmation ("${stripped.substring(0, 40)}..."), cancelling idle detection`);
|
||||
this.log(
|
||||
`Substantial output during confirmation ("${stripped.substring(0, 40)}..."), cancelling idle detection`
|
||||
);
|
||||
this.cancelCompletionConfirm();
|
||||
}
|
||||
return;
|
||||
@@ -1526,7 +1569,7 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
|
||||
// Legacy fallback: detect prompt characters (still useful for waiting_* states)
|
||||
const hasPrompt = this.PROMPT_PATTERNS.some(pattern => data.includes(pattern));
|
||||
const hasPrompt = this.PROMPT_PATTERNS.some((pattern) => data.includes(pattern));
|
||||
if (hasPrompt) {
|
||||
this.promptDetected = true;
|
||||
this.workingDetected = false;
|
||||
@@ -1572,7 +1615,6 @@ export class RespawnController extends EventEmitter {
|
||||
* @fires stepCompleted - With step 'update'
|
||||
*/
|
||||
private checkUpdateComplete(): void {
|
||||
this.clearIdleTimer();
|
||||
this.log('Update completed (ready indicator)');
|
||||
this.emit('stepCompleted', 'update');
|
||||
|
||||
@@ -1607,7 +1649,6 @@ export class RespawnController extends EventEmitter {
|
||||
* @fires stepCompleted - With step 'clear'
|
||||
*/
|
||||
private checkClearComplete(): void {
|
||||
this.clearIdleTimer();
|
||||
// Clear the fallback timer since we got prompt detection
|
||||
this.cancelTrackedTimer('clear-fallback', this.clearFallbackTimer, 'prompt detected');
|
||||
this.clearFallbackTimer = null;
|
||||
@@ -1631,7 +1672,6 @@ export class RespawnController extends EventEmitter {
|
||||
* @fires stepCompleted - With step 'init' (if no kickstart)
|
||||
*/
|
||||
private checkInitComplete(): void {
|
||||
this.clearIdleTimer();
|
||||
this.log('/init completed (ready indicator)');
|
||||
|
||||
// P2-004: Record step completion
|
||||
@@ -1679,7 +1719,6 @@ export class RespawnController extends EventEmitter {
|
||||
* @fires stepCompleted - With step 'init'
|
||||
*/
|
||||
private checkMonitoringInitIdle(): void {
|
||||
this.clearIdleTimer();
|
||||
if (this.stepTimer) {
|
||||
clearTimeout(this.stepTimer);
|
||||
this.stepTimer = null;
|
||||
@@ -1706,7 +1745,7 @@ export class RespawnController extends EventEmitter {
|
||||
if (this._state === 'stopped') return;
|
||||
const prompt = this.config.kickstartPrompt!;
|
||||
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
|
||||
await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
|
||||
await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
|
||||
this.emit('stepSent', 'kickstart', prompt);
|
||||
this.setState('waiting_kickstart');
|
||||
this.promptDetected = false;
|
||||
@@ -1721,7 +1760,6 @@ export class RespawnController extends EventEmitter {
|
||||
* @fires stepCompleted - With step 'kickstart'
|
||||
*/
|
||||
private checkKickstartComplete(): void {
|
||||
this.clearIdleTimer();
|
||||
this.log('Kickstart completed (ready indicator)');
|
||||
this.emit('stepCompleted', 'kickstart');
|
||||
|
||||
@@ -1731,22 +1769,10 @@ export class RespawnController extends EventEmitter {
|
||||
this.completeCycle();
|
||||
}
|
||||
|
||||
// Note: Legacy startIdleTimer removed - now using completion-based detection
|
||||
// with startCompletionConfirmTimer() and startNoOutputTimer() instead.
|
||||
|
||||
/** Clear the idle detection timer if running (legacy cleanup) */
|
||||
private clearIdleTimer(): void {
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear all timers (idle, step, completion confirm, no-output, pre-filter, step confirm, auto-accept, hook confirm, and clear fallback) */
|
||||
/** Clear all timers (step, completion confirm, no-output, pre-filter, step confirm, auto-accept, hook confirm, and clear fallback) */
|
||||
private clearTimers(): void {
|
||||
// Clear tracked timers map first to avoid stale entries during individual cleanup
|
||||
this.activeTimers.clear();
|
||||
this.clearIdleTimer();
|
||||
if (this.stepTimer) {
|
||||
clearTimeout(this.stepTimer);
|
||||
this.stepTimer = null;
|
||||
@@ -1831,11 +1857,16 @@ export class RespawnController extends EventEmitter {
|
||||
if (this.stuckRecoveryCount < this.config.maxStuckRecoveries) {
|
||||
this.stuckRecoveryCount++;
|
||||
this.logAction('stuck', `Recovery attempt ${this.stuckRecoveryCount}/${this.config.maxStuckRecoveries}`);
|
||||
this.log(`Stuck-state recovery triggered (state: ${this._state}, duration: ${Math.round(durationMs / 1000)}s, attempt: ${this.stuckRecoveryCount})`);
|
||||
this.log(
|
||||
`Stuck-state recovery triggered (state: ${this._state}, duration: ${Math.round(durationMs / 1000)}s, attempt: ${this.stuckRecoveryCount})`
|
||||
);
|
||||
this.emit('stuckStateRecovery', this._state, durationMs, this.stuckRecoveryCount);
|
||||
this.handleStuckStateRecovery();
|
||||
} else {
|
||||
this.logAction('stuck', `Max recoveries (${this.config.maxStuckRecoveries}) reached - manual intervention needed`);
|
||||
this.logAction(
|
||||
'stuck',
|
||||
`Max recoveries (${this.config.maxStuckRecoveries}) reached - manual intervention needed`
|
||||
);
|
||||
this.log(`Stuck-state: max recoveries reached, manual intervention needed`);
|
||||
}
|
||||
return;
|
||||
@@ -1950,12 +1981,7 @@ export class RespawnController extends EventEmitter {
|
||||
* Start a tracked timer with UI countdown support.
|
||||
* Emits timerStarted event and tracks the timer for UI display.
|
||||
*/
|
||||
private startTrackedTimer(
|
||||
name: string,
|
||||
durationMs: number,
|
||||
callback: () => void,
|
||||
reason?: string
|
||||
): NodeJS.Timeout {
|
||||
private startTrackedTimer(name: string, durationMs: number, callback: () => void, reason?: string): NodeJS.Timeout {
|
||||
const now = Date.now();
|
||||
const endsAt = now + durationMs;
|
||||
|
||||
@@ -1989,7 +2015,7 @@ export class RespawnController extends EventEmitter {
|
||||
*/
|
||||
getActiveTimers(): ActiveTimerInfo[] {
|
||||
const now = Date.now();
|
||||
return Array.from(this.activeTimers.values()).map(t => ({
|
||||
return Array.from(this.activeTimers.values()).map((t) => ({
|
||||
name: t.name,
|
||||
remainingMs: Math.max(0, t.endsAt - now),
|
||||
totalMs: t.durationMs,
|
||||
@@ -2038,7 +2064,7 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
|
||||
// Check the rolling window (includes current data, catches both complete and split patterns)
|
||||
return this.WORKING_PATTERNS.some(pattern => this.workingPatternWindow.includes(pattern));
|
||||
return this.WORKING_PATTERNS.some((pattern) => this.workingPatternWindow.includes(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2175,7 +2201,9 @@ export class RespawnController extends EventEmitter {
|
||||
|
||||
// If on cooldown, don't start check - wait for cooldown to expire
|
||||
if (this.aiChecker.isOnCooldown()) {
|
||||
this.log(`AI check on cooldown (${Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000)}s remaining), waiting...`);
|
||||
this.log(
|
||||
`AI check on cooldown (${Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000)}s remaining), waiting...`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2207,67 +2235,70 @@ export class RespawnController extends EventEmitter {
|
||||
// Get the terminal buffer for analysis
|
||||
const buffer = this.terminalBuffer.value;
|
||||
|
||||
this.aiChecker.check(buffer).then((result) => {
|
||||
// If state changed while checking (e.g., cancelled), ignore result
|
||||
if (this._state !== 'ai_checking') {
|
||||
this.log(`AI check result ignored (state is now ${this._state})`);
|
||||
return;
|
||||
}
|
||||
this.aiChecker
|
||||
.check(buffer)
|
||||
.then((result) => {
|
||||
// If state changed while checking (e.g., cancelled), ignore result
|
||||
if (this._state !== 'ai_checking') {
|
||||
this.log(`AI check result ignored (state is now ${this._state})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate this is the result for the current check (not a stale one)
|
||||
if (this._currentAiCheckId !== checkId) {
|
||||
this.log(`AI check result ignored (stale check ID: ${checkId.substring(0, 8)})`);
|
||||
return;
|
||||
}
|
||||
// Validate this is the result for the current check (not a stale one)
|
||||
if (this._currentAiCheckId !== checkId) {
|
||||
this.log(`AI check result ignored (stale check ID: ${checkId.substring(0, 8)})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.verdict === 'IDLE') {
|
||||
// Cancel any pending confirmation timers - AI has spoken
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE');
|
||||
this.completionConfirmTimer = null;
|
||||
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE');
|
||||
this.preFilterTimer = null;
|
||||
if (result.verdict === 'IDLE') {
|
||||
// Cancel any pending confirmation timers - AI has spoken
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE');
|
||||
this.completionConfirmTimer = null;
|
||||
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE');
|
||||
this.preFilterTimer = null;
|
||||
|
||||
this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`);
|
||||
this.emit('aiCheckCompleted', result);
|
||||
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
|
||||
} else if (result.verdict === 'WORKING') {
|
||||
// Cancel timers and go to cooldown
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING');
|
||||
this.completionConfirmTimer = null;
|
||||
this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`);
|
||||
this.emit('aiCheckCompleted', result);
|
||||
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
|
||||
} else if (result.verdict === 'WORKING') {
|
||||
// Cancel timers and go to cooldown
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING');
|
||||
this.completionConfirmTimer = null;
|
||||
|
||||
this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`);
|
||||
this.emit('aiCheckCompleted', result);
|
||||
this.setState('watching');
|
||||
this.log(`AI check says WORKING, returning to watching with ${this.config.aiIdleCheckCooldownMs}ms cooldown`);
|
||||
// Restart timers so the controller retries after cooldown expires
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
} else {
|
||||
// ERROR verdict
|
||||
this.logAction('ai-check', `Error: ${result.reasoning}`);
|
||||
this.emit('aiCheckFailed', result.reasoning);
|
||||
this.setState('watching');
|
||||
// Restart timers to allow retry
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
}
|
||||
}).catch((err) => {
|
||||
// Validate this is the error for the current check
|
||||
if (this._currentAiCheckId !== checkId) {
|
||||
return; // Stale check, ignore error
|
||||
}
|
||||
if (this._state === 'stopped') return; // Guard against stopped state
|
||||
if (this._state === 'ai_checking') {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
||||
this.emit('aiCheckFailed', errorMsg);
|
||||
this.setState('watching');
|
||||
this.log(`AI check error: ${errorMsg}`);
|
||||
// Restart timers to allow retry
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
}
|
||||
});
|
||||
this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`);
|
||||
this.emit('aiCheckCompleted', result);
|
||||
this.setState('watching');
|
||||
this.log(`AI check says WORKING, returning to watching with ${this.config.aiIdleCheckCooldownMs}ms cooldown`);
|
||||
// Restart timers so the controller retries after cooldown expires
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
} else {
|
||||
// ERROR verdict
|
||||
this.logAction('ai-check', `Error: ${result.reasoning}`);
|
||||
this.emit('aiCheckFailed', result.reasoning);
|
||||
this.setState('watching');
|
||||
// Restart timers to allow retry
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
// Validate this is the error for the current check
|
||||
if (this._currentAiCheckId !== checkId) {
|
||||
return; // Stale check, ignore error
|
||||
}
|
||||
if (this._state === 'stopped') return; // Guard against stopped state
|
||||
if (this._state === 'ai_checking') {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
||||
this.emit('aiCheckFailed', errorMsg);
|
||||
this.setState('watching');
|
||||
this.log(`AI check error: ${errorMsg}`);
|
||||
// Restart timers to allow retry
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Auto-Accept Prompt Methods ==========
|
||||
@@ -2351,7 +2382,9 @@ export class RespawnController extends EventEmitter {
|
||||
// Stage 2: AI confirmation (if enabled and available)
|
||||
if (this.config.aiPlanCheckEnabled && this.planChecker.status !== 'disabled') {
|
||||
if (this.planChecker.isOnCooldown()) {
|
||||
this.log(`Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)`);
|
||||
this.log(
|
||||
`Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.planChecker.status === 'checking') {
|
||||
@@ -2396,7 +2429,7 @@ export class RespawnController extends EventEmitter {
|
||||
// Working patterns before the selector are from earlier work and don't matter.
|
||||
const selectorIndex = stripped.lastIndexOf(selectorMatch[0]);
|
||||
const afterSelector = stripped.slice(selectorIndex + selectorMatch[0].length);
|
||||
const hasWorking = this.WORKING_PATTERNS.some(pattern => afterSelector.includes(pattern));
|
||||
const hasWorking = this.WORKING_PATTERNS.some((pattern) => afterSelector.includes(pattern));
|
||||
if (hasWorking) return false;
|
||||
|
||||
return true;
|
||||
@@ -2416,36 +2449,39 @@ export class RespawnController extends EventEmitter {
|
||||
this.logAction('plan-check', 'Spawning AI plan checker');
|
||||
this.emit('planCheckStarted');
|
||||
|
||||
this.planChecker.check(buffer).then((result) => {
|
||||
// Discard stale result if new output arrived during check
|
||||
if (this.lastOutputTime > this.planCheckStartTime) {
|
||||
this.logAction('plan-check', 'Result discarded (output arrived during check)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.verdict === 'PLAN_MODE') {
|
||||
// Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began)
|
||||
if (this._state !== 'watching') {
|
||||
this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`);
|
||||
this.planChecker
|
||||
.check(buffer)
|
||||
.then((result) => {
|
||||
// Discard stale result if new output arrived during check
|
||||
if (this.lastOutputTime > this.planCheckStartTime) {
|
||||
this.logAction('plan-check', 'Result discarded (output arrived during check)');
|
||||
return;
|
||||
}
|
||||
this.emit('planCheckCompleted', result);
|
||||
this.logAction('plan-check', 'Verdict: PLAN_MODE - sending Enter immediately');
|
||||
this.sendAutoAcceptEnter();
|
||||
// No cooldown needed - we're taking action
|
||||
} else if (result.verdict === 'NOT_PLAN_MODE') {
|
||||
this.emit('planCheckCompleted', result);
|
||||
this.logAction('plan-check', `Verdict: NOT_PLAN_MODE - ${result.reasoning}`);
|
||||
} else {
|
||||
// ERROR verdict
|
||||
this.emit('planCheckFailed', result.reasoning);
|
||||
this.logAction('plan-check', `Error: ${result.reasoning}`);
|
||||
}
|
||||
}).catch((err) => {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.emit('planCheckFailed', errorMsg);
|
||||
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
||||
});
|
||||
|
||||
if (result.verdict === 'PLAN_MODE') {
|
||||
// Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began)
|
||||
if (this._state !== 'watching') {
|
||||
this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`);
|
||||
return;
|
||||
}
|
||||
this.emit('planCheckCompleted', result);
|
||||
this.logAction('plan-check', 'Verdict: PLAN_MODE - sending Enter immediately');
|
||||
this.sendAutoAcceptEnter();
|
||||
// No cooldown needed - we're taking action
|
||||
} else if (result.verdict === 'NOT_PLAN_MODE') {
|
||||
this.emit('planCheckCompleted', result);
|
||||
this.logAction('plan-check', `Verdict: NOT_PLAN_MODE - ${result.reasoning}`);
|
||||
} else {
|
||||
// ERROR verdict
|
||||
this.emit('planCheckFailed', result.reasoning);
|
||||
this.logAction('plan-check', `Error: ${result.reasoning}`);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.emit('planCheckFailed', errorMsg);
|
||||
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2765,11 +2801,13 @@ export class RespawnController extends EventEmitter {
|
||||
|
||||
this.log(`Idle confirmed via: ${reason}`);
|
||||
const status = this.getDetectionStatus();
|
||||
this.log(`Detection status: confidence=${status.confidenceLevel}%, ` +
|
||||
`completion=${status.completionMessageDetected}, ` +
|
||||
`silent=${status.outputSilent}, ` +
|
||||
`tokensStable=${status.tokensStable}, ` +
|
||||
`noWorking=${status.workingPatternsAbsent}`);
|
||||
this.log(
|
||||
`Detection status: confidence=${status.confidenceLevel}%, ` +
|
||||
`completion=${status.completionMessageDetected}, ` +
|
||||
`silent=${status.outputSilent}, ` +
|
||||
`tokensStable=${status.tokensStable}, ` +
|
||||
`noWorking=${status.workingPatternsAbsent}`
|
||||
);
|
||||
|
||||
// ========== Agent Teams Integration ==========
|
||||
// Check if session has active teammates — don't respawn while team is working
|
||||
@@ -2777,7 +2815,10 @@ export class RespawnController extends EventEmitter {
|
||||
const count = this.teamWatcher.getActiveTeammateCount(this.session.id);
|
||||
this.log(`Respawn blocked - ${count} active teammate(s) working`);
|
||||
this.logAction('team', `Active teammates: ${count}`);
|
||||
this.emit('respawnBlocked', { reason: 'active_teammates', details: `${count} teammate(s) still working` });
|
||||
this.emit('respawnBlocked', {
|
||||
reason: 'active_teammates',
|
||||
details: `${count} teammate(s) still working`,
|
||||
});
|
||||
this.setState('watching');
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
@@ -2792,7 +2833,10 @@ export class RespawnController extends EventEmitter {
|
||||
if (circuitBreaker.state === 'OPEN') {
|
||||
this.log(`Respawn blocked - Circuit breaker OPEN: ${circuitBreaker.reason}`);
|
||||
this.logAction('ralph', `Circuit breaker OPEN: ${circuitBreaker.reason}`);
|
||||
this.emit('respawnBlocked', { reason: 'circuit_breaker_open', details: circuitBreaker.reason });
|
||||
this.emit('respawnBlocked', {
|
||||
reason: 'circuit_breaker_open',
|
||||
details: circuitBreaker.reason,
|
||||
});
|
||||
this.setState('watching');
|
||||
// Don't restart timers - wait for manual reset or circuit breaker resolution
|
||||
return;
|
||||
@@ -2804,7 +2848,10 @@ export class RespawnController extends EventEmitter {
|
||||
if (statusBlock?.exitSignal) {
|
||||
this.log(`Respawn paused - RALPH_STATUS EXIT_SIGNAL=true`);
|
||||
this.logAction('ralph', `Exit signal detected: ${statusBlock.recommendation || 'Task complete'}`);
|
||||
this.emit('respawnBlocked', { reason: 'exit_signal', details: statusBlock.recommendation || 'Task complete' });
|
||||
this.emit('respawnBlocked', {
|
||||
reason: 'exit_signal',
|
||||
details: statusBlock.recommendation || 'Task complete',
|
||||
});
|
||||
this.setState('watching');
|
||||
// Don't restart timers - loop is complete
|
||||
return;
|
||||
@@ -2814,7 +2861,10 @@ export class RespawnController extends EventEmitter {
|
||||
if (statusBlock?.status === 'BLOCKED') {
|
||||
this.log(`Respawn blocked - RALPH_STATUS reports BLOCKED`);
|
||||
this.logAction('ralph', `Claude reported BLOCKED: ${statusBlock.recommendation || 'Needs human intervention'}`);
|
||||
this.emit('respawnBlocked', { reason: 'status_blocked', details: statusBlock.recommendation || 'Needs human intervention' });
|
||||
this.emit('respawnBlocked', {
|
||||
reason: 'status_blocked',
|
||||
details: statusBlock.recommendation || 'Needs human intervention',
|
||||
});
|
||||
this.setState('watching');
|
||||
return;
|
||||
}
|
||||
@@ -2846,7 +2896,10 @@ export class RespawnController extends EventEmitter {
|
||||
if (this.session.status === 'error') {
|
||||
this.log('Skipping respawn cycle - session is in error state');
|
||||
this.logAction('health', 'Respawn skipped: Session error state');
|
||||
this.emit('respawnBlocked', { reason: 'session_error', details: 'Session is in error state' });
|
||||
this.emit('respawnBlocked', {
|
||||
reason: 'session_error',
|
||||
details: 'Session is in error state',
|
||||
});
|
||||
this.setState('watching');
|
||||
return;
|
||||
}
|
||||
@@ -2907,7 +2960,7 @@ export class RespawnController extends EventEmitter {
|
||||
this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`);
|
||||
}
|
||||
|
||||
const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
|
||||
const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
|
||||
this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`);
|
||||
await this.session.writeViaMux(input);
|
||||
this.emit('stepSent', 'update', updatePrompt);
|
||||
@@ -2937,7 +2990,7 @@ export class RespawnController extends EventEmitter {
|
||||
this.stepTimer = null;
|
||||
if (this._state === 'stopped') return;
|
||||
this.logAction('command', 'Sending: /clear');
|
||||
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
|
||||
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
|
||||
this.emit('stepSent', 'clear', '/clear');
|
||||
this.setState('waiting_clear');
|
||||
this.promptDetected = false;
|
||||
@@ -2981,7 +3034,7 @@ export class RespawnController extends EventEmitter {
|
||||
this.stepTimer = null;
|
||||
if (this._state === 'stopped') return;
|
||||
this.logAction('command', 'Sending: /init');
|
||||
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
||||
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
||||
this.emit('stepSent', 'init', '/init');
|
||||
this.setState('waiting_init');
|
||||
this.promptDetected = false;
|
||||
@@ -3052,9 +3105,13 @@ export class RespawnController extends EventEmitter {
|
||||
this.config = { ...this.config, ...filteredConfig };
|
||||
|
||||
// Sync AI checker config if relevant fields changed
|
||||
if (config.aiIdleCheckEnabled !== undefined || config.aiIdleCheckModel !== undefined ||
|
||||
config.aiIdleCheckMaxContext !== undefined || config.aiIdleCheckTimeoutMs !== undefined ||
|
||||
config.aiIdleCheckCooldownMs !== undefined) {
|
||||
if (
|
||||
config.aiIdleCheckEnabled !== undefined ||
|
||||
config.aiIdleCheckModel !== undefined ||
|
||||
config.aiIdleCheckMaxContext !== undefined ||
|
||||
config.aiIdleCheckTimeoutMs !== undefined ||
|
||||
config.aiIdleCheckCooldownMs !== undefined
|
||||
) {
|
||||
this.aiChecker.updateConfig({
|
||||
enabled: this.config.aiIdleCheckEnabled,
|
||||
model: this.config.aiIdleCheckModel,
|
||||
@@ -3065,9 +3122,13 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
|
||||
// Sync plan checker config if relevant fields changed
|
||||
if (config.aiPlanCheckEnabled !== undefined || config.aiPlanCheckModel !== undefined ||
|
||||
config.aiPlanCheckMaxContext !== undefined || config.aiPlanCheckTimeoutMs !== undefined ||
|
||||
config.aiPlanCheckCooldownMs !== undefined) {
|
||||
if (
|
||||
config.aiPlanCheckEnabled !== undefined ||
|
||||
config.aiPlanCheckModel !== undefined ||
|
||||
config.aiPlanCheckMaxContext !== undefined ||
|
||||
config.aiPlanCheckTimeoutMs !== undefined ||
|
||||
config.aiPlanCheckCooldownMs !== undefined
|
||||
) {
|
||||
this.planChecker.updateConfig({
|
||||
enabled: this.config.aiPlanCheckEnabled,
|
||||
model: this.config.aiPlanCheckModel,
|
||||
@@ -3276,7 +3337,7 @@ export class RespawnController extends EventEmitter {
|
||||
|
||||
const now = Date.now();
|
||||
const metrics: RespawnCycleMetrics = {
|
||||
...this.currentCycleMetrics as RespawnCycleMetrics,
|
||||
...(this.currentCycleMetrics as RespawnCycleMetrics),
|
||||
completedAt: now,
|
||||
durationMs: now - (this.currentCycleMetrics.startedAt ?? now),
|
||||
outcome,
|
||||
@@ -3299,7 +3360,9 @@ export class RespawnController extends EventEmitter {
|
||||
// Clear current cycle
|
||||
this.currentCycleMetrics = null;
|
||||
|
||||
this.log(`Cycle #${metrics.cycleNumber} metrics: ${outcome}, duration=${metrics.durationMs}ms, idle_detection=${metrics.idleDetectionMs}ms`);
|
||||
this.log(
|
||||
`Cycle #${metrics.cycleNumber} metrics: ${outcome}, duration=${metrics.durationMs}ms, idle_detection=${metrics.idleDetectionMs}ms`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3333,8 +3396,8 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
|
||||
// Recalculate averages using all recent metrics
|
||||
const durations = this.recentCycleMetrics.map(m => m.durationMs);
|
||||
const idleTimes = this.recentCycleMetrics.map(m => m.idleDetectionMs);
|
||||
const durations = this.recentCycleMetrics.map((m) => m.durationMs);
|
||||
const idleTimes = this.recentCycleMetrics.map((m) => m.idleDetectionMs);
|
||||
|
||||
if (durations.length > 0) {
|
||||
agg.avgCycleDurationMs = Math.round(durations.reduce((a, b) => a + b, 0) / durations.length);
|
||||
@@ -3347,9 +3410,7 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
|
||||
// Calculate success rate
|
||||
agg.successRate = agg.totalCycles > 0
|
||||
? Math.round((agg.successfulCycles / agg.totalCycles) * 100)
|
||||
: 100;
|
||||
agg.successRate = agg.totalCycles > 0 ? Math.round((agg.successfulCycles / agg.totalCycles) * 100) : 100;
|
||||
|
||||
agg.lastUpdatedAt = Date.now();
|
||||
}
|
||||
@@ -3392,18 +3453,18 @@ export class RespawnController extends EventEmitter {
|
||||
// Weighted average (cycle success is most important)
|
||||
const weights = {
|
||||
cycleSuccess: 0.35,
|
||||
circuitBreaker: 0.20,
|
||||
iterationProgress: 0.20,
|
||||
circuitBreaker: 0.2,
|
||||
iterationProgress: 0.2,
|
||||
aiChecker: 0.15,
|
||||
stuckRecovery: 0.10,
|
||||
stuckRecovery: 0.1,
|
||||
};
|
||||
|
||||
const score = Math.round(
|
||||
components.cycleSuccess * weights.cycleSuccess +
|
||||
components.circuitBreaker * weights.circuitBreaker +
|
||||
components.iterationProgress * weights.iterationProgress +
|
||||
components.aiChecker * weights.aiChecker +
|
||||
components.stuckRecovery * weights.stuckRecovery
|
||||
components.circuitBreaker * weights.circuitBreaker +
|
||||
components.iterationProgress * weights.iterationProgress +
|
||||
components.aiChecker * weights.aiChecker +
|
||||
components.stuckRecovery * weights.stuckRecovery
|
||||
);
|
||||
|
||||
// Determine status
|
||||
@@ -3446,10 +3507,14 @@ export class RespawnController extends EventEmitter {
|
||||
|
||||
const cb = tracker.circuitBreakerStatus;
|
||||
switch (cb.state) {
|
||||
case 'CLOSED': return 100;
|
||||
case 'HALF_OPEN': return 50;
|
||||
case 'OPEN': return 0;
|
||||
default: return 100;
|
||||
case 'CLOSED':
|
||||
return 100;
|
||||
case 'HALF_OPEN':
|
||||
return 50;
|
||||
case 'OPEN':
|
||||
return 0;
|
||||
default:
|
||||
return 100;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3527,8 +3592,10 @@ export class RespawnController extends EventEmitter {
|
||||
status: HealthStatus,
|
||||
components: RalphLoopHealthScore['components']
|
||||
): string {
|
||||
const lowest = Object.entries(components).reduce((min, [key, val]) =>
|
||||
val < min.val ? { key, val } : min, { key: '', val: 100 });
|
||||
const lowest = Object.entries(components).reduce((min, [key, val]) => (val < min.val ? { key, val } : min), {
|
||||
key: '',
|
||||
val: 100,
|
||||
});
|
||||
|
||||
if (status === 'excellent') {
|
||||
return `Ralph Loop is operating excellently (${score}/100). All systems healthy.`;
|
||||
|
||||
@@ -139,13 +139,10 @@ export class RunSummaryTracker {
|
||||
// Record state transition
|
||||
if (oldState && oldState !== newState) {
|
||||
this.stats.stateTransitions++;
|
||||
this.addEvent(
|
||||
'respawn_state_change',
|
||||
'info',
|
||||
`State: ${oldState} → ${newState}`,
|
||||
details,
|
||||
{ from: oldState, to: newState }
|
||||
);
|
||||
this.addEvent('respawn_state_change', 'info', `State: ${oldState} → ${newState}`, details, {
|
||||
from: oldState,
|
||||
to: newState,
|
||||
});
|
||||
}
|
||||
|
||||
// Update state tracking
|
||||
@@ -214,7 +211,11 @@ export class RunSummaryTracker {
|
||||
'info',
|
||||
`Token milestone: ${this.formatTokens(currentMilestone)}`,
|
||||
`Input: ${this.formatTokens(inputTokens)}, Output: ${this.formatTokens(outputTokens)}`,
|
||||
{ total, input: inputTokens, output: outputTokens }
|
||||
{
|
||||
total,
|
||||
input: inputTokens,
|
||||
output: outputTokens,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -264,13 +265,9 @@ export class RunSummaryTracker {
|
||||
* Record a Ralph completion detection.
|
||||
*/
|
||||
recordRalphCompletion(phrase: string): void {
|
||||
this.addEvent(
|
||||
'ralph_completion',
|
||||
'success',
|
||||
'Ralph completion detected',
|
||||
`Phrase: ${phrase}`,
|
||||
{ phrase }
|
||||
);
|
||||
this.addEvent('ralph_completion', 'success', 'Ralph completion detected', `Phrase: ${phrase}`, {
|
||||
phrase,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,9 +275,7 @@ export class RunSummaryTracker {
|
||||
*/
|
||||
recordHookEvent(eventType: string, data?: Record<string, unknown>): void {
|
||||
const severity: RunSummaryEventSeverity =
|
||||
eventType === 'stop' ? 'warning' :
|
||||
eventType === 'permission_prompt' ? 'info' :
|
||||
'info';
|
||||
eventType === 'stop' ? 'warning' : eventType === 'permission_prompt' ? 'info' : 'info';
|
||||
|
||||
this.addEvent(
|
||||
'hook_event',
|
||||
@@ -311,13 +306,10 @@ export class RunSummaryTracker {
|
||||
* Record session started.
|
||||
*/
|
||||
recordSessionStarted(mode: string, workingDir: string): void {
|
||||
this.addEvent(
|
||||
'session_started',
|
||||
'success',
|
||||
'Session started',
|
||||
`Mode: ${mode}, Dir: ${workingDir}`,
|
||||
{ mode, workingDir }
|
||||
);
|
||||
this.addEvent('session_started', 'success', 'Session started', `Mode: ${mode}, Dir: ${workingDir}`, {
|
||||
mode,
|
||||
workingDir,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,7 +62,7 @@ export class SessionLifecycleLog {
|
||||
}
|
||||
|
||||
const lines = raw.trim().split('\n').filter(Boolean);
|
||||
let entries: LifecycleEntry[] = [];
|
||||
const entries: LifecycleEntry[] = [];
|
||||
|
||||
// Parse in reverse (newest first) for efficiency with limit
|
||||
for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) {
|
||||
|
||||
@@ -54,6 +54,7 @@ interface SessionHandlers {
|
||||
error: (data: string) => void;
|
||||
completion: (phrase: string) => void;
|
||||
exit: () => void;
|
||||
taskError: (taskId: string, error: string) => void;
|
||||
}
|
||||
|
||||
export class SessionManager extends EventEmitter {
|
||||
@@ -102,7 +103,7 @@ export class SessionManager extends EventEmitter {
|
||||
// Create a new lock promise that others will wait on
|
||||
// Define unlock first to ensure it's always in scope before promise assignment
|
||||
let unlock!: () => void;
|
||||
const lockPromise = new Promise<void>(resolve => {
|
||||
const lockPromise = new Promise<void>((resolve) => {
|
||||
unlock = resolve;
|
||||
});
|
||||
this._sessionCreationLock = lockPromise;
|
||||
@@ -134,12 +135,16 @@ export class SessionManager extends EventEmitter {
|
||||
this.emit('sessionStopped', session.id);
|
||||
this.updateSessionState(session);
|
||||
},
|
||||
taskError: (taskId: string, error: string) => {
|
||||
this.emit('sessionTaskError', session.id, taskId, error);
|
||||
},
|
||||
};
|
||||
|
||||
session.on('output', handlers.output);
|
||||
session.on('error', handlers.error);
|
||||
session.on('completion', handlers.completion);
|
||||
session.on('exit', handlers.exit);
|
||||
session.on('taskError', handlers.taskError);
|
||||
|
||||
// Store handlers for later cleanup
|
||||
this.sessionHandlers.set(session.id, handlers);
|
||||
@@ -183,6 +188,7 @@ export class SessionManager extends EventEmitter {
|
||||
session.off('error', handlers.error);
|
||||
session.off('completion', handlers.completion);
|
||||
session.off('exit', handlers.exit);
|
||||
session.off('taskError', handlers.taskError);
|
||||
this.sessionHandlers.delete(id);
|
||||
}
|
||||
|
||||
@@ -196,9 +202,7 @@ export class SessionManager extends EventEmitter {
|
||||
* Uses Promise.allSettled to ensure all sessions are stopped even if some fail.
|
||||
*/
|
||||
async stopAllSessions(): Promise<void> {
|
||||
const stopPromises = Array.from(this.sessions.keys()).map((id) =>
|
||||
this.stopSession(id)
|
||||
);
|
||||
const stopPromises = Array.from(this.sessions.keys()).map((id) => this.stopSession(id));
|
||||
const results = await Promise.allSettled(stopPromises);
|
||||
// Log any failures but don't throw - best effort cleanup
|
||||
for (const result of results) {
|
||||
@@ -292,4 +296,3 @@ export function getSessionManager(): SessionManager {
|
||||
}
|
||||
return managerInstance;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,19 +18,26 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as pty from 'node-pty';
|
||||
import { SessionState, SessionStatus, SessionConfig, RalphTrackerState, RalphTodoItem, ActiveBashTool, NiceConfig, DEFAULT_NICE_CONFIG, type ClaudeMode, type SessionMode, type OpenCodeConfig } from './types.js';
|
||||
import {
|
||||
SessionState,
|
||||
SessionStatus,
|
||||
SessionConfig,
|
||||
RalphTrackerState,
|
||||
RalphTodoItem,
|
||||
ActiveBashTool,
|
||||
NiceConfig,
|
||||
DEFAULT_NICE_CONFIG,
|
||||
type ClaudeMode,
|
||||
type SessionMode,
|
||||
type OpenCodeConfig,
|
||||
} from './types.js';
|
||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||
import { RalphTracker } from './ralph-tracker.js';
|
||||
import { BashToolParser } from './bash-tool-parser.js';
|
||||
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
||||
import { LRUMap } from './utils/lru-map.js';
|
||||
import {
|
||||
ANSI_ESCAPE_PATTERN_FULL,
|
||||
TOKEN_PATTERN,
|
||||
SPINNER_PATTERN,
|
||||
MAX_SESSION_TOKENS,
|
||||
} from './utils/index.js';
|
||||
import { ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, SPINNER_PATTERN, MAX_SESSION_TOKENS } from './utils/index.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_SIZE,
|
||||
TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE,
|
||||
@@ -42,7 +49,6 @@ import {
|
||||
|
||||
export type { BackgroundTask } from './task-tracker.js';
|
||||
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
||||
export { withTimeout };
|
||||
|
||||
/** Line buffer flush interval (100ms) - forces processing of partial lines */
|
||||
const LINE_BUFFER_FLUSH_INTERVAL = 100;
|
||||
@@ -68,6 +74,7 @@ const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
|
||||
|
||||
// Filter out terminal focus escape sequences (focus in/out reports)
|
||||
// ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g;
|
||||
|
||||
// Pattern to match Task tool invocations in terminal output
|
||||
@@ -78,8 +85,10 @@ const TASK_TOOL_PATTERN = /\b(Explore|Task|Bash|Plan|general-purpose)\(([^)]+)\)
|
||||
|
||||
// Pre-compiled patterns for hot paths (avoid regex compilation per call)
|
||||
/** Pattern to strip leading ANSI escapes and whitespace from terminal buffer */
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const LEADING_ANSI_WHITESPACE_PATTERN = /^(\x1b\[\??[\d;]*[A-Za-z]|[\s\r\n])+/;
|
||||
/** Pattern to match Ctrl+L (form feed) characters */
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CTRL_L_PATTERN = /\x0c/g;
|
||||
/** Pattern to split by newlines (CR or LF) */
|
||||
const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
||||
@@ -87,33 +96,6 @@ const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
||||
// Claude CLI PATH resolution — shared utility
|
||||
import { getAugmentedPath } from './utils/claude-cli-resolver.js';
|
||||
|
||||
/**
|
||||
* Wraps a promise with a timeout to prevent indefinite hangs.
|
||||
* If the promise doesn't resolve within the timeout, rejects with TimeoutError.
|
||||
*
|
||||
* @param promise - The promise to wrap
|
||||
* @param timeoutMs - Timeout in milliseconds
|
||||
* @param operation - Description of the operation for error messages
|
||||
* @returns Promise that resolves/rejects with the original result or timeout error
|
||||
*/
|
||||
function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
operation: string
|
||||
): Promise<T> {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(`${operation} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a JSON message from Claude CLI's stream-json output format.
|
||||
* Messages are newline-delimited JSON objects parsed from PTY output.
|
||||
@@ -193,7 +175,12 @@ export interface SessionEvents {
|
||||
/** Active Bash tools list updated */
|
||||
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
||||
/** CLI info (version, model, account) updated */
|
||||
cliInfoUpdated: (info: { version: string | null; model: string | null; accountType: string | null; latestVersion: string | null }) => void;
|
||||
cliInfoUpdated: (info: {
|
||||
version: string | null;
|
||||
model: string | null;
|
||||
accountType: string | null;
|
||||
latestVersion: string | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
// SessionMode is imported from types.ts (single source of truth)
|
||||
@@ -258,7 +245,7 @@ export class Session extends EventEmitter {
|
||||
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
|
||||
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
||||
private rejectPromise: ((reason: Error) => void) | null = null;
|
||||
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
||||
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
||||
private _isWorking: boolean = false;
|
||||
private _lastPromptTime: number = 0;
|
||||
private activityTimeout: NodeJS.Timeout | null = null;
|
||||
@@ -356,7 +343,9 @@ export class Session extends EventEmitter {
|
||||
// Task descriptions parsed from terminal output (e.g., "Explore(Description)")
|
||||
// Used to correlate with SubagentWatcher discoveries for better window titles
|
||||
// Uses LRUMap for automatic eviction at MAX_TASK_DESCRIPTIONS limit
|
||||
private _recentTaskDescriptions: LRUMap<number, string> = new LRUMap({ maxSize: Session.MAX_TASK_DESCRIPTIONS });
|
||||
private _recentTaskDescriptions: LRUMap<number, string> = new LRUMap({
|
||||
maxSize: Session.MAX_TASK_DESCRIPTIONS,
|
||||
});
|
||||
|
||||
// Throttle expensive PTY processing (Ralph, bash parser, task descriptions)
|
||||
// Accumulates clean data between processing windows to avoid running regex on every chunk
|
||||
@@ -365,26 +354,28 @@ export class Session extends EventEmitter {
|
||||
private _expensiveProcessTimer: NodeJS.Timeout | null = null;
|
||||
private static readonly EXPENSIVE_PROCESS_INTERVAL_MS = 150; // Process at most every 150ms
|
||||
|
||||
constructor(config: Partial<SessionConfig> & {
|
||||
workingDir: string;
|
||||
mode?: SessionMode;
|
||||
name?: string;
|
||||
/** Terminal multiplexer instance (tmux) */
|
||||
mux?: TerminalMultiplexer;
|
||||
/** Whether to use multiplexer wrapping */
|
||||
useMux?: boolean;
|
||||
/** Existing mux session for restored sessions */
|
||||
muxSession?: MuxSession;
|
||||
niceConfig?: NiceConfig; // Nice prioritying configuration
|
||||
/** Claude model override (e.g., 'opus', 'sonnet', 'haiku') */
|
||||
model?: string;
|
||||
/** Claude CLI startup permission mode */
|
||||
claudeMode?: ClaudeMode;
|
||||
/** Comma-separated allowed tools (for 'allowedTools' mode) */
|
||||
allowedTools?: string;
|
||||
/** OpenCode configuration (only for mode === 'opencode') */
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
}) {
|
||||
constructor(
|
||||
config: Partial<SessionConfig> & {
|
||||
workingDir: string;
|
||||
mode?: SessionMode;
|
||||
name?: string;
|
||||
/** Terminal multiplexer instance (tmux) */
|
||||
mux?: TerminalMultiplexer;
|
||||
/** Whether to use multiplexer wrapping */
|
||||
useMux?: boolean;
|
||||
/** Existing mux session for restored sessions */
|
||||
muxSession?: MuxSession;
|
||||
niceConfig?: NiceConfig; // Nice prioritying configuration
|
||||
/** Claude model override (e.g., 'opus', 'sonnet', 'haiku') */
|
||||
model?: string;
|
||||
/** Claude CLI startup permission mode */
|
||||
claudeMode?: ClaudeMode;
|
||||
/** Comma-separated allowed tools (for 'allowedTools' mode) */
|
||||
allowedTools?: string;
|
||||
/** OpenCode configuration (only for mode === 'opencode') */
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
}
|
||||
) {
|
||||
super();
|
||||
this.setMaxListeners(25);
|
||||
|
||||
@@ -472,7 +463,6 @@ export class Session extends EventEmitter {
|
||||
this._bashToolParser.on('toolStart', this._bashToolHandlers.toolStart);
|
||||
this._bashToolParser.on('toolEnd', this._bashToolHandlers.toolEnd);
|
||||
this._bashToolParser.on('toolsUpdate', this._bashToolHandlers.toolsUpdate);
|
||||
|
||||
}
|
||||
|
||||
get status(): SessionStatus {
|
||||
@@ -673,17 +663,23 @@ export class Session extends EventEmitter {
|
||||
restoreTokens(inputTokens: number, outputTokens: number, totalCost: number): void {
|
||||
// Sanity check: reject absurdly large individual values
|
||||
if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) {
|
||||
console.warn(`[Session ${this.id}] Rejected absurd restored tokens: input=${inputTokens}, output=${outputTokens}`);
|
||||
console.warn(
|
||||
`[Session ${this.id}] Rejected absurd restored tokens: input=${inputTokens}, output=${outputTokens}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Check token sum doesn't overflow MAX_SESSION_TOKENS
|
||||
if (inputTokens + outputTokens > MAX_SESSION_TOKENS) {
|
||||
console.warn(`[Session ${this.id}] Rejected token sum overflow: input=${inputTokens} + output=${outputTokens} = ${inputTokens + outputTokens} > ${MAX_SESSION_TOKENS}`);
|
||||
console.warn(
|
||||
`[Session ${this.id}] Rejected token sum overflow: input=${inputTokens} + output=${outputTokens} = ${inputTokens + outputTokens} > ${MAX_SESSION_TOKENS}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Reject negative values
|
||||
if (inputTokens < 0 || outputTokens < 0 || totalCost < 0) {
|
||||
console.warn(`[Session ${this.id}] Rejected negative restored tokens: input=${inputTokens}, output=${outputTokens}, cost=${totalCost}`);
|
||||
console.warn(
|
||||
`[Session ${this.id}] Rejected negative restored tokens: input=${inputTokens}, output=${outputTokens}, cost=${totalCost}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -722,7 +718,9 @@ export class Session extends EventEmitter {
|
||||
if (threshold !== undefined) {
|
||||
// Validate threshold bounds
|
||||
if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) {
|
||||
console.warn(`[Session ${this.id}] Invalid autoClear threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_CLEAR_THRESHOLD}.`);
|
||||
console.warn(
|
||||
`[Session ${this.id}] Invalid autoClear threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_CLEAR_THRESHOLD}.`
|
||||
);
|
||||
this._autoClearThreshold = Session.DEFAULT_AUTO_CLEAR_THRESHOLD;
|
||||
} else {
|
||||
this._autoClearThreshold = threshold;
|
||||
@@ -747,7 +745,9 @@ export class Session extends EventEmitter {
|
||||
if (threshold !== undefined) {
|
||||
// Validate threshold bounds
|
||||
if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) {
|
||||
console.warn(`[Session ${this.id}] Invalid autoCompact threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_COMPACT_THRESHOLD}.`);
|
||||
console.warn(
|
||||
`[Session ${this.id}] Invalid autoCompact threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_COMPACT_THRESHOLD}.`
|
||||
);
|
||||
this._autoCompactThreshold = Session.DEFAULT_AUTO_COMPACT_THRESHOLD;
|
||||
} else {
|
||||
this._autoCompactThreshold = threshold;
|
||||
@@ -911,7 +911,9 @@ export class Session extends EventEmitter {
|
||||
this._lastActivityAt = Date.now();
|
||||
|
||||
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude';
|
||||
console.log(`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : ''));
|
||||
console.log(
|
||||
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
||||
);
|
||||
|
||||
// If mux wrapping is enabled, create or attach to a mux session
|
||||
if (this._useMux && this._mux) {
|
||||
@@ -928,16 +930,21 @@ export class Session extends EventEmitter {
|
||||
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||
const newPid = await this._mux.respawnPane({
|
||||
sessionId: this.id, workingDir: this.workingDir, mode: this.mode,
|
||||
niceConfig: this._niceConfig, model: this._model, claudeMode: this._claudeMode,
|
||||
allowedTools: this._allowedTools, openCodeConfig: this._openCodeConfig,
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
niceConfig: this._niceConfig,
|
||||
model: this._model,
|
||||
claudeMode: this._claudeMode,
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
});
|
||||
if (!newPid) {
|
||||
console.error('[Session] Failed to respawn pane, will create new session');
|
||||
needsNewSession = true;
|
||||
} else {
|
||||
// Wait a moment for the respawned process to fully start
|
||||
await new Promise(resolve => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -948,9 +955,14 @@ export class Session extends EventEmitter {
|
||||
} else {
|
||||
// Create a new mux session
|
||||
this._muxSession = await this._mux.createSession({
|
||||
sessionId: this.id, workingDir: this.workingDir, mode: this.mode,
|
||||
name: this._name, niceConfig: this._niceConfig, model: this._model,
|
||||
claudeMode: this._claudeMode, allowedTools: this._allowedTools,
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
name: this._name,
|
||||
niceConfig: this._niceConfig,
|
||||
model: this._model,
|
||||
claudeMode: this._claudeMode,
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
});
|
||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||
@@ -962,13 +974,21 @@ export class Session extends EventEmitter {
|
||||
this.ptyProcess = pty.spawn(
|
||||
this._mux.getAttachCommand(),
|
||||
this._mux.getAttachArgs(this._muxSession!.muxName),
|
||||
{
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', TERM: 'xterm-256color', COLORTERM: undefined, CLAUDECODE: undefined },
|
||||
});
|
||||
{
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: {
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: undefined,
|
||||
CLAUDECODE: undefined,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Set claudeSessionId immediately since we passed --session-id to Claude
|
||||
// The mux manager passes --session-id ${sessionId} to Claude
|
||||
@@ -1010,9 +1030,7 @@ export class Session extends EventEmitter {
|
||||
// Clean the buffer - remove mux init junk before actual content
|
||||
// Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH),
|
||||
// clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace
|
||||
this._terminalBuffer.set(
|
||||
bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, '')
|
||||
);
|
||||
this._terminalBuffer.set(bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, ''));
|
||||
// Signal client to refresh
|
||||
this.emit('clearTerminal');
|
||||
}
|
||||
@@ -1081,9 +1099,7 @@ export class Session extends EventEmitter {
|
||||
|
||||
this.ptyProcess.onData((rawData: string) => {
|
||||
// Filter out focus escape sequences and Ctrl+L (form feed)
|
||||
const data = rawData
|
||||
.replace(FOCUS_ESCAPE_FILTER, '')
|
||||
.replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
|
||||
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '').replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
|
||||
if (!data) return; // Skip if only filtered sequences
|
||||
|
||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||
@@ -1252,8 +1268,12 @@ export class Session extends EventEmitter {
|
||||
// Only check if spinner didn't already trigger working state
|
||||
if (!this._isWorking) {
|
||||
const cleanData = getCleanData();
|
||||
if (cleanData.includes('Thinking') || cleanData.includes('Writing') ||
|
||||
cleanData.includes('Reading') || cleanData.includes('Running')) {
|
||||
if (
|
||||
cleanData.includes('Thinking') ||
|
||||
cleanData.includes('Writing') ||
|
||||
cleanData.includes('Reading') ||
|
||||
cleanData.includes('Running')
|
||||
) {
|
||||
this._isWorking = true;
|
||||
this._status = 'busy';
|
||||
this.emit('working');
|
||||
@@ -1293,7 +1313,10 @@ export class Session extends EventEmitter {
|
||||
|
||||
// Use user's default shell or bash
|
||||
const shell = process.env.SHELL || '/bin/bash';
|
||||
console.log('[Session] Starting shell session with:', shell + (this._useMux ? ` (with ${this._mux!.backend})` : ''));
|
||||
console.log(
|
||||
'[Session] Starting shell session with:',
|
||||
shell + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
||||
);
|
||||
|
||||
// If mux wrapping is enabled, create or attach to a mux session
|
||||
if (this._useMux && this._mux) {
|
||||
@@ -1308,12 +1331,17 @@ export class Session extends EventEmitter {
|
||||
let needsNewSession = false;
|
||||
if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) {
|
||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||
const newPid = await this._mux.respawnPane({ sessionId: this.id, workingDir: this.workingDir, mode: 'shell', niceConfig: this._niceConfig });
|
||||
const newPid = await this._mux.respawnPane({
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: 'shell',
|
||||
niceConfig: this._niceConfig,
|
||||
});
|
||||
if (!newPid) {
|
||||
console.error('[Session] Failed to respawn pane, will create new session');
|
||||
needsNewSession = true;
|
||||
} else {
|
||||
await new Promise(resolve => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1323,7 +1351,13 @@ export class Session extends EventEmitter {
|
||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||
} else {
|
||||
// Create a new mux session
|
||||
this._muxSession = await this._mux.createSession({ sessionId: this.id, workingDir: this.workingDir, mode: 'shell', name: this._name, niceConfig: this._niceConfig });
|
||||
this._muxSession = await this._mux.createSession({
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: 'shell',
|
||||
name: this._name,
|
||||
niceConfig: this._niceConfig,
|
||||
});
|
||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||
// No extra sleep — createSession() already waits for tmux readiness
|
||||
}
|
||||
@@ -1333,13 +1367,21 @@ export class Session extends EventEmitter {
|
||||
this.ptyProcess = pty.spawn(
|
||||
this._mux.getAttachCommand(),
|
||||
this._mux.getAttachArgs(this._muxSession!.muxName),
|
||||
{
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', TERM: 'xterm-256color', COLORTERM: undefined, CLAUDECODE: undefined },
|
||||
});
|
||||
{
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: {
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: undefined,
|
||||
CLAUDECODE: undefined,
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr);
|
||||
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
||||
@@ -1474,7 +1516,7 @@ export class Session extends EventEmitter {
|
||||
this._messages = [];
|
||||
this._lineBuffer = '';
|
||||
this._lastActivityAt = Date.now();
|
||||
this._promptResolved = false; // Reset race condition guard
|
||||
this._promptResolved = false; // Reset race condition guard
|
||||
|
||||
this.resolvePromise = resolve;
|
||||
this.rejectPromise = reject;
|
||||
@@ -1482,14 +1524,13 @@ export class Session extends EventEmitter {
|
||||
try {
|
||||
// Spawn claude in a real PTY
|
||||
const model = options?.model;
|
||||
console.log('[Session] Spawning PTY for claude with prompt:', prompt.substring(0, 50), model ? `(model: ${model})` : '');
|
||||
console.log(
|
||||
'[Session] Spawning PTY for claude with prompt:',
|
||||
prompt.substring(0, 50),
|
||||
model ? `(model: ${model})` : ''
|
||||
);
|
||||
|
||||
const args = [
|
||||
'-p',
|
||||
'--verbose',
|
||||
'--dangerously-skip-permissions',
|
||||
'--output-format', 'stream-json',
|
||||
];
|
||||
const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json'];
|
||||
if (model) {
|
||||
args.push('--model', model);
|
||||
}
|
||||
@@ -1517,7 +1558,10 @@ export class Session extends EventEmitter {
|
||||
});
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
||||
this.emit('error', `Failed to spawn Claude: ${spawnErr instanceof Error ? spawnErr.message : String(spawnErr)}`);
|
||||
this.emit(
|
||||
'error',
|
||||
`Failed to spawn Claude: ${spawnErr instanceof Error ? spawnErr.message : String(spawnErr)}`
|
||||
);
|
||||
throw spawnErr;
|
||||
}
|
||||
|
||||
@@ -1561,7 +1605,7 @@ export class Session extends EventEmitter {
|
||||
this.rejectPromise = null;
|
||||
|
||||
// Find result from parsed messages or use text output
|
||||
const resultMsg = this._messages.find(m => m.type === 'result');
|
||||
const resultMsg = this._messages.find((m) => m.type === 'result');
|
||||
|
||||
if (resultMsg && !resultMsg.is_error) {
|
||||
this._status = 'idle';
|
||||
@@ -1579,13 +1623,15 @@ export class Session extends EventEmitter {
|
||||
} else {
|
||||
this._status = 'idle';
|
||||
if (resolve) {
|
||||
resolve({ result: this._textOutput.value || this._terminalBuffer.value, cost: this._totalCost });
|
||||
resolve({
|
||||
result: this._textOutput.value || this._terminalBuffer.value,
|
||||
cost: this._totalCost,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('exit', exitCode);
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
this._status = 'error';
|
||||
reject(err);
|
||||
@@ -1649,8 +1695,8 @@ export class Session extends EventEmitter {
|
||||
|
||||
// Extract Claude session ID from messages (can be in any message type)
|
||||
// Support both sessionId (camelCase) and session_id (snake_case)
|
||||
const msgSessionId = (msg as unknown as Record<string, unknown>).sessionId as string | undefined
|
||||
?? msg.session_id;
|
||||
const msgSessionId =
|
||||
((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
|
||||
if (msgSessionId && !this._claudeSessionId) {
|
||||
this._claudeSessionId = msgSessionId;
|
||||
}
|
||||
@@ -1689,7 +1735,10 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
} catch (parseErr) {
|
||||
// Not JSON, just regular output - this is expected for non-JSON lines
|
||||
console.debug('[Session] Line not JSON (expected for text output):', parseErr instanceof Error ? parseErr.message : parseErr);
|
||||
console.debug(
|
||||
'[Session] Line not JSON (expected for text output):',
|
||||
parseErr instanceof Error ? parseErr.message : parseErr
|
||||
);
|
||||
this._textOutput.append(line + '\n');
|
||||
}
|
||||
} else if (trimmed) {
|
||||
@@ -1829,7 +1878,9 @@ export class Session extends EventEmitter {
|
||||
// Safety: Reject M values that would result in > 500k tokens
|
||||
// Claude's context window is ~200k, so anything claiming millions is likely a false match
|
||||
if (tokenCount > 0.5) {
|
||||
console.warn(`[Session ${this.id}] Rejected suspicious M token value: ${tokenMatch[0]} (would be ${tokenCount * 1000000} tokens)`);
|
||||
console.warn(
|
||||
`[Session ${this.id}] Rejected suspicious M token value: ${tokenMatch[0]} (would be ${tokenCount * 1000000} tokens)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
tokenCount *= 1000000;
|
||||
@@ -1850,7 +1901,9 @@ export class Session extends EventEmitter {
|
||||
// Safety: Reject suspiciously large jumps (max 100k per update)
|
||||
const MAX_DELTA_PER_UPDATE = 100_000;
|
||||
if (delta > MAX_DELTA_PER_UPDATE) {
|
||||
console.warn(`[Session ${this.id}] Rejected suspicious token jump: ${currentTotal} -> ${tokenCount} (delta: ${delta})`);
|
||||
console.warn(
|
||||
`[Session ${this.id}] Rejected suspicious token jump: ${currentTotal} -> ${tokenCount} (delta: ${delta})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1874,7 +1927,12 @@ export class Session extends EventEmitter {
|
||||
if (this._cliInfoParsed) return;
|
||||
|
||||
// Quick pre-checks
|
||||
if (!cleanData.includes('Claude') && !cleanData.includes('current:') && !cleanData.includes('Opus') && !cleanData.includes('Sonnet')) {
|
||||
if (
|
||||
!cleanData.includes('Claude') &&
|
||||
!cleanData.includes('current:') &&
|
||||
!cleanData.includes('Opus') &&
|
||||
!cleanData.includes('Sonnet')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let changed = false;
|
||||
@@ -1960,14 +2018,12 @@ export class Session extends EventEmitter {
|
||||
if (this._isStopped) return;
|
||||
|
||||
// Send /compact command with optional prompt
|
||||
const compactCmd = this._autoCompactPrompt
|
||||
? `/compact ${this._autoCompactPrompt}\r`
|
||||
: '/compact\r';
|
||||
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
|
||||
await this.writeViaMux(compactCmd);
|
||||
this.emit('autoCompact', {
|
||||
tokens: totalTokens,
|
||||
threshold: this._autoCompactThreshold,
|
||||
prompt: this._autoCompactPrompt || undefined
|
||||
prompt: this._autoCompactPrompt || undefined,
|
||||
});
|
||||
|
||||
// Wait a moment then re-enable (longer than clear since compact takes time)
|
||||
@@ -2121,8 +2177,18 @@ export class Session extends EventEmitter {
|
||||
async sendInput(input: string): Promise<void> {
|
||||
this._status = 'busy';
|
||||
this._lastActivityAt = Date.now();
|
||||
this.runPrompt(input).catch(err => {
|
||||
this.runPrompt(input).catch((err) => {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
// Clean up task state so the task queue doesn't get stuck
|
||||
if (this._currentTaskId) {
|
||||
const taskId = this._currentTaskId;
|
||||
this._currentTaskId = null;
|
||||
this._status = 'idle';
|
||||
this._lastActivityAt = Date.now();
|
||||
this.emit('taskError', taskId, errorMsg);
|
||||
} else {
|
||||
this._status = 'idle';
|
||||
}
|
||||
this.emit('error', errorMsg);
|
||||
});
|
||||
}
|
||||
@@ -2260,7 +2326,7 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
// Give it a moment to terminate gracefully
|
||||
await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
|
||||
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
|
||||
|
||||
// Force kill with SIGKILL if still alive
|
||||
try {
|
||||
|
||||
@@ -18,7 +18,16 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkS
|
||||
import { writeFile, rename, unlink, copyFile, access } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState, GlobalStats, createInitialGlobalStats, TokenStats, TokenUsageEntry } from './types.js';
|
||||
import {
|
||||
AppState,
|
||||
createInitialState,
|
||||
RalphSessionState,
|
||||
createInitialRalphSessionState,
|
||||
GlobalStats,
|
||||
createInitialGlobalStats,
|
||||
TokenStats,
|
||||
TokenUsageEntry,
|
||||
} from './types.js';
|
||||
import { MAX_SESSION_TOKENS } from './utils/index.js';
|
||||
|
||||
/** Debounce delay for batching state writes (ms) */
|
||||
@@ -53,6 +62,8 @@ export class StateStore {
|
||||
private filePath: string;
|
||||
private saveTimeout: NodeJS.Timeout | null = null;
|
||||
private dirty: boolean = false;
|
||||
private dirtySessions = new Set<string>();
|
||||
private cachedSessionJsons = new Map<string, string>();
|
||||
|
||||
// Inner state storage (separate from main state to reduce write frequency)
|
||||
private ralphStates: Map<string, RalphSessionState> = new Map();
|
||||
@@ -88,6 +99,10 @@ export class StateStore {
|
||||
this.ralphStatePath = this.filePath.replace('.json', '-inner.json');
|
||||
this.state = this.load();
|
||||
this.state.config.stateFilePath = this.filePath;
|
||||
// Pre-populate session cache for loaded state
|
||||
for (const [id, session] of Object.entries(this.state.sessions)) {
|
||||
this.cachedSessionJsons.set(id, JSON.stringify(session));
|
||||
}
|
||||
this.loadRalphStates();
|
||||
}
|
||||
|
||||
@@ -167,6 +182,65 @@ export class StateStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble JSON string with incremental per-session caching.
|
||||
* Only dirty sessions are re-serialized; clean sessions use cached JSON fragments.
|
||||
*/
|
||||
private assembleStateJson(): string {
|
||||
// Re-serialize dirty sessions and update cache
|
||||
for (const id of this.dirtySessions) {
|
||||
const session = this.state.sessions[id];
|
||||
if (session) {
|
||||
this.cachedSessionJsons.set(id, JSON.stringify(session));
|
||||
} else {
|
||||
this.cachedSessionJsons.delete(id);
|
||||
}
|
||||
}
|
||||
this.dirtySessions.clear();
|
||||
|
||||
// Build sessions object from cached fragments
|
||||
const sessionParts: string[] = [];
|
||||
for (const [id, session] of Object.entries(this.state.sessions)) {
|
||||
let json = this.cachedSessionJsons.get(id);
|
||||
if (!json) {
|
||||
// Session not in cache (loaded from disk or set via direct state mutation)
|
||||
json = JSON.stringify(session);
|
||||
this.cachedSessionJsons.set(id, json);
|
||||
}
|
||||
sessionParts.push(`${JSON.stringify(id)}:${json}`);
|
||||
}
|
||||
|
||||
// Prune stale cache entries (sessions removed via direct state mutation)
|
||||
if (this.cachedSessionJsons.size > Object.keys(this.state.sessions).length) {
|
||||
for (const cachedId of this.cachedSessionJsons.keys()) {
|
||||
if (!(cachedId in this.state.sessions)) {
|
||||
this.cachedSessionJsons.delete(cachedId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build final JSON: sessions from cache, everything else re-serialized (tiny)
|
||||
const sessionsJson = `{${sessionParts.join(',')}}`;
|
||||
|
||||
// Serialize non-session fields individually (they're small)
|
||||
const parts: string[] = [
|
||||
`"sessions":${sessionsJson}`,
|
||||
`"tasks":${JSON.stringify(this.state.tasks)}`,
|
||||
`"ralphLoop":${JSON.stringify(this.state.ralphLoop)}`,
|
||||
`"config":${JSON.stringify(this.state.config)}`,
|
||||
];
|
||||
|
||||
// Optional fields
|
||||
if (this.state.globalStats) {
|
||||
parts.push(`"globalStats":${JSON.stringify(this.state.globalStats)}`);
|
||||
}
|
||||
if (this.state.tokenStats) {
|
||||
parts.push(`"tokenStats":${JSON.stringify(this.state.tokenStats)}`);
|
||||
}
|
||||
|
||||
return `{${parts.join(',')}}`;
|
||||
}
|
||||
|
||||
private async _doSaveAsync(): Promise<void> {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
@@ -190,17 +264,29 @@ export class StateStore {
|
||||
|
||||
// Step 1: Serialize state (validates it's JSON-safe)
|
||||
try {
|
||||
json = JSON.stringify(this.state);
|
||||
} catch (err) {
|
||||
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
|
||||
this.consecutiveSaveFailures++;
|
||||
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
|
||||
this.circuitBreakerOpen = true;
|
||||
json = this.assembleStateJson();
|
||||
} catch (assembleErr) {
|
||||
// Fallback to full serialization if incremental assembly fails
|
||||
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
|
||||
this.cachedSessionJsons.clear();
|
||||
this.dirtySessions.clear();
|
||||
try {
|
||||
json = JSON.stringify(this.state);
|
||||
} catch (err) {
|
||||
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
|
||||
this.consecutiveSaveFailures++;
|
||||
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
|
||||
this.circuitBreakerOpen = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear dirty flag BEFORE async I/O so mutations during write re-set it.
|
||||
// The state snapshot is already captured in `json` above.
|
||||
this.dirty = false;
|
||||
|
||||
// Step 2: Create backup via file copy (async, no read+parse+write)
|
||||
try {
|
||||
await access(this.filePath);
|
||||
@@ -214,8 +300,6 @@ export class StateStore {
|
||||
await writeFile(tempPath, json, 'utf-8');
|
||||
await rename(tempPath, this.filePath);
|
||||
|
||||
// Success! Clear dirty flag AFTER write completes
|
||||
this.dirty = false;
|
||||
this.consecutiveSaveFailures = 0;
|
||||
if (this.circuitBreakerOpen) {
|
||||
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
|
||||
@@ -223,6 +307,8 @@ export class StateStore {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[StateStore] Failed to write state file:', err);
|
||||
// Re-mark dirty so the data is retried on the next save cycle
|
||||
this.dirty = true;
|
||||
this.consecutiveSaveFailures++;
|
||||
|
||||
// Try to clean up temp file on error
|
||||
@@ -266,15 +352,23 @@ export class StateStore {
|
||||
let json: string;
|
||||
|
||||
try {
|
||||
json = JSON.stringify(this.state);
|
||||
} catch (err) {
|
||||
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
|
||||
this.consecutiveSaveFailures++;
|
||||
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
|
||||
this.circuitBreakerOpen = true;
|
||||
json = this.assembleStateJson();
|
||||
} catch (assembleErr) {
|
||||
// Fallback to full serialization if incremental assembly fails
|
||||
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
|
||||
this.cachedSessionJsons.clear();
|
||||
this.dirtySessions.clear();
|
||||
try {
|
||||
json = JSON.stringify(this.state);
|
||||
} catch (err) {
|
||||
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
|
||||
this.consecutiveSaveFailures++;
|
||||
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
|
||||
this.circuitBreakerOpen = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Backup via atomic copy (avoids reading entire file into memory)
|
||||
@@ -299,7 +393,11 @@ export class StateStore {
|
||||
} catch (err) {
|
||||
console.error('[StateStore] Failed to write state file:', err);
|
||||
this.consecutiveSaveFailures++;
|
||||
try { if (existsSync(tempPath)) unlinkSync(tempPath); } catch { /* ignore */ }
|
||||
try {
|
||||
if (existsSync(tempPath)) unlinkSync(tempPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly');
|
||||
this.circuitBreakerOpen = true;
|
||||
@@ -370,12 +468,15 @@ export class StateStore {
|
||||
/** Sets a session state and triggers a debounced save. */
|
||||
setSession(id: string, session: AppState['sessions'][string]) {
|
||||
this.state.sessions[id] = session;
|
||||
this.dirtySessions.add(id);
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Removes a session state and triggers a debounced save. */
|
||||
removeSession(id: string) {
|
||||
delete this.state.sessions[id];
|
||||
this.cachedSessionJsons.delete(id);
|
||||
this.dirtySessions.delete(id);
|
||||
this.save();
|
||||
}
|
||||
|
||||
@@ -384,7 +485,10 @@ export class StateStore {
|
||||
* @param activeSessionIds - Set of currently active session IDs
|
||||
* @returns Number of sessions cleaned up
|
||||
*/
|
||||
cleanupStaleSessions(activeSessionIds: Set<string>): { count: number; cleaned: Array<{ id: string; name?: string }> } {
|
||||
cleanupStaleSessions(activeSessionIds: Set<string>): {
|
||||
count: number;
|
||||
cleaned: Array<{ id: string; name?: string }>;
|
||||
} {
|
||||
const allSessionIds = Object.keys(this.state.sessions);
|
||||
const cleaned: Array<{ id: string; name?: string }> = [];
|
||||
|
||||
@@ -393,6 +497,8 @@ export class StateStore {
|
||||
const name = this.state.sessions[sessionId]?.name;
|
||||
cleaned.push({ id: sessionId, name });
|
||||
delete this.state.sessions[sessionId];
|
||||
this.cachedSessionJsons.delete(sessionId);
|
||||
this.dirtySessions.delete(sessionId);
|
||||
// Also clean up Ralph state for this session
|
||||
this.ralphStates.delete(sessionId);
|
||||
}
|
||||
@@ -455,6 +561,8 @@ export class StateStore {
|
||||
this.state = createInitialState();
|
||||
this.state.config.stateFilePath = this.filePath;
|
||||
this.ralphStates.clear();
|
||||
this.cachedSessionJsons.clear();
|
||||
this.dirtySessions.clear();
|
||||
this.saveNow(); // Immediate save for reset operations
|
||||
this.saveRalphStatesNow();
|
||||
}
|
||||
@@ -481,7 +589,9 @@ export class StateStore {
|
||||
}
|
||||
// Reject negative values
|
||||
if (inputTokens < 0 || outputTokens < 0 || cost < 0) {
|
||||
console.warn(`[StateStore] Rejected negative global stats: input=${inputTokens}, output=${outputTokens}, cost=${cost}`);
|
||||
console.warn(
|
||||
`[StateStore] Rejected negative global stats: input=${inputTokens}, output=${outputTokens}, cost=${cost}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -505,7 +615,9 @@ export class StateStore {
|
||||
* Returns aggregate stats combining global (deleted sessions) + active sessions.
|
||||
* @param activeSessions Map of active session states
|
||||
*/
|
||||
getAggregateStats(activeSessions: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }>): {
|
||||
getAggregateStats(
|
||||
activeSessions: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }>
|
||||
): {
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
totalCost: number;
|
||||
@@ -602,7 +714,7 @@ export class StateStore {
|
||||
}
|
||||
|
||||
// Find or create today's entry
|
||||
let todayEntry = stats.daily.find(e => e.date === today);
|
||||
let todayEntry = stats.daily.find((e) => e.date === today);
|
||||
if (!todayEntry) {
|
||||
todayEntry = {
|
||||
date: today,
|
||||
@@ -617,10 +729,7 @@ export class StateStore {
|
||||
// Accumulate tokens
|
||||
todayEntry.inputTokens += inputTokens;
|
||||
todayEntry.outputTokens += outputTokens;
|
||||
todayEntry.estimatedCost = this.calculateEstimatedCost(
|
||||
todayEntry.inputTokens,
|
||||
todayEntry.outputTokens
|
||||
);
|
||||
todayEntry.estimatedCost = this.calculateEstimatedCost(todayEntry.inputTokens, todayEntry.outputTokens);
|
||||
|
||||
// Only increment session count for unique sessions
|
||||
if (sessionId && !this.dailySessionIds.has(sessionId)) {
|
||||
|
||||
@@ -77,16 +77,18 @@ export interface SubagentTranscriptEntry {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
};
|
||||
content: string | Array<{
|
||||
type: 'text' | 'tool_use' | 'tool_result';
|
||||
text?: string;
|
||||
name?: string;
|
||||
id?: string; // tool_use_id for tool_use blocks
|
||||
tool_use_id?: string; // tool_use_id for tool_result blocks
|
||||
input?: Record<string, unknown>;
|
||||
content?: string | Array<{ type: string; text?: string }>; // tool_result content
|
||||
is_error?: boolean; // For tool_result errors
|
||||
}>;
|
||||
content:
|
||||
| string
|
||||
| Array<{
|
||||
type: 'text' | 'tool_use' | 'tool_result';
|
||||
text?: string;
|
||||
name?: string;
|
||||
id?: string; // tool_use_id for tool_use blocks
|
||||
tool_use_id?: string; // tool_use_id for tool_result blocks
|
||||
input?: Record<string, unknown>;
|
||||
content?: string | Array<{ type: string; text?: string }>; // tool_result content
|
||||
is_error?: boolean; // For tool_result errors
|
||||
}>;
|
||||
};
|
||||
data?: {
|
||||
type: string;
|
||||
@@ -135,11 +137,11 @@ const MAX_TRACKED_AGENTS = 500; // Maximum agents to track (LRU eviction when ex
|
||||
|
||||
// Internal Claude Code agent patterns to filter out (not real user-initiated subagents)
|
||||
const INTERNAL_AGENT_PATTERNS = [
|
||||
/^\[?SUGGESTION MODE/i, // Claude Code's internal suggestion mode
|
||||
/^Suggest what user might/i, // Suggestion mode prompt variant
|
||||
/aprompt/i, // Internal prompt agent (anywhere in string)
|
||||
/^a\s?prompt/i, // Variants of internal prompt agent
|
||||
/^prompt$/i, // Just "prompt"
|
||||
/^\[?SUGGESTION MODE/i, // Claude Code's internal suggestion mode
|
||||
/^Suggest what user might/i, // Suggestion mode prompt variant
|
||||
/aprompt/i, // Internal prompt agent (anywhere in string)
|
||||
/^a\s?prompt/i, // Variants of internal prompt agent
|
||||
/^prompt$/i, // Just "prompt"
|
||||
];
|
||||
|
||||
// Minimum description length - very short descriptions are likely internal or malformed
|
||||
@@ -158,8 +160,9 @@ const FILE_CONTENT_DEBOUNCE_MS = 100; // Debounce delay for file content updates
|
||||
|
||||
export class SubagentWatcher extends EventEmitter {
|
||||
private filePositions = new Map<string, number>();
|
||||
private fileWatchers = new Map<string, FSWatcher>();
|
||||
private dirWatchers = new Map<string, FSWatcher>();
|
||||
// Per-file debounce timers for directory watcher (replaces per-file FSWatchers)
|
||||
private fileDebouncers = new Map<string, NodeJS.Timeout>();
|
||||
private agentInfo = new Map<string, SubagentInfo>();
|
||||
private idleTimers = new Map<string, NodeJS.Timeout>();
|
||||
private pollInterval: NodeJS.Timeout | null = null;
|
||||
@@ -178,7 +181,8 @@ export class SubagentWatcher extends EventEmitter {
|
||||
private parentDescriptionCache = new Map<string, { descriptions: Map<string, string>; timestamp: number }>();
|
||||
// Store error handlers for FSWatchers to enable proper cleanup (prevent memory leaks)
|
||||
private dirWatcherErrorHandlers = new Map<string, (error: Error) => void>();
|
||||
private fileWatcherErrorHandlers = new Map<string, (error: Error) => void>();
|
||||
// Map filePath → { projectHash, sessionId } for directory watcher file-change handling
|
||||
private fileAgentContext = new Map<string, { projectHash: string; sessionId: string }>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -194,7 +198,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
if (!description) return false;
|
||||
// Filter out very short descriptions (likely internal or malformed)
|
||||
if (description.length < MIN_DESCRIPTION_LENGTH) return true;
|
||||
return INTERNAL_AGENT_PATTERNS.some(pattern => pattern.test(description));
|
||||
return INTERNAL_AGENT_PATTERNS.some((pattern) => pattern.test(description));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,7 +267,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
if (await this.checkSubagentFileAlive(info)) continue;
|
||||
|
||||
// Tier 2: Cached PID check (~0.1ms per agent)
|
||||
if (info.pid && await this.checkPidAlive(info.pid)) continue;
|
||||
if (info.pid && (await this.checkPidAlive(info.pid))) continue;
|
||||
|
||||
// Tiers 1+2 failed — need expensive scan for this agent
|
||||
needsFullScan.push(info);
|
||||
@@ -322,20 +326,35 @@ export class SubagentWatcher extends EventEmitter {
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
const pids = pgrepOutput.trim().split('\n').filter(Boolean).map(s => parseInt(s, 10)).filter(n => !Number.isNaN(n));
|
||||
const pids = pgrepOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((s) => parseInt(s, 10))
|
||||
.filter((n) => !Number.isNaN(n));
|
||||
|
||||
// Read /proc for all PIDs in parallel
|
||||
await Promise.all(pids.map(async (pid) => {
|
||||
let environ = '';
|
||||
let cmdline = '';
|
||||
try { environ = await readFile(`/proc/${pid}/environ`, 'utf8'); } catch { /* skip */ }
|
||||
try { cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf8'); } catch { /* skip */ }
|
||||
// Skip main Codeman-managed Claude processes — only track subagents
|
||||
if (environ.includes('CODEMAN_MUX=1')) return;
|
||||
if (environ || cmdline) {
|
||||
result.set(pid, { environ, cmdline });
|
||||
}
|
||||
}));
|
||||
await Promise.all(
|
||||
pids.map(async (pid) => {
|
||||
let environ = '';
|
||||
let cmdline = '';
|
||||
try {
|
||||
environ = await readFile(`/proc/${pid}/environ`, 'utf8');
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
try {
|
||||
cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf8');
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
// Skip main Codeman-managed Claude processes — only track subagents
|
||||
if (environ.includes('CODEMAN_MUX=1')) return;
|
||||
if (environ || cmdline) {
|
||||
result.set(pid, { environ, cmdline });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Update cached PIDs on tracked agents
|
||||
for (const [pid, procInfo] of result) {
|
||||
@@ -409,17 +428,12 @@ export class SubagentWatcher extends EventEmitter {
|
||||
this.livenessInterval = null;
|
||||
}
|
||||
|
||||
// Remove error handlers before closing watchers to prevent memory leak
|
||||
for (const [filePath, handler] of this.fileWatcherErrorHandlers) {
|
||||
const watcher = this.fileWatchers.get(filePath);
|
||||
if (watcher) watcher.off('error', handler);
|
||||
// Clear file debouncers
|
||||
for (const timer of this.fileDebouncers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.fileWatcherErrorHandlers.clear();
|
||||
|
||||
for (const watcher of this.fileWatchers.values()) {
|
||||
watcher.close();
|
||||
}
|
||||
this.fileWatchers.clear();
|
||||
this.fileDebouncers.clear();
|
||||
this.fileAgentContext.clear();
|
||||
|
||||
// Remove error handlers before closing watchers to prevent memory leak
|
||||
for (const [dir, handler] of this.dirWatcherErrorHandlers) {
|
||||
@@ -517,11 +531,11 @@ export class SubagentWatcher extends EventEmitter {
|
||||
this.agentInfo.delete(agentId);
|
||||
this.pendingToolCalls.delete(agentId);
|
||||
this.filePositions.delete(info.filePath);
|
||||
const watcher = this.fileWatchers.get(info.filePath);
|
||||
if (watcher) {
|
||||
watcher.close();
|
||||
this.fileWatchers.delete(info.filePath);
|
||||
this.fileWatcherErrorHandlers.delete(info.filePath);
|
||||
this.fileAgentContext.delete(info.filePath);
|
||||
const debounceTimer = this.fileDebouncers.get(info.filePath);
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
this.fileDebouncers.delete(info.filePath);
|
||||
}
|
||||
const timer = this.idleTimers.get(agentId);
|
||||
if (timer) {
|
||||
@@ -567,9 +581,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
*/
|
||||
getSubagentsForSession(workingDir: string): SubagentInfo[] {
|
||||
const projectHash = this.getProjectHash(workingDir);
|
||||
return Array.from(this.agentInfo.values()).filter(
|
||||
(info) => info.projectHash === projectHash
|
||||
);
|
||||
return Array.from(this.agentInfo.values()).filter((info) => info.projectHash === projectHash);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -607,7 +619,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
*/
|
||||
getStats(): {
|
||||
agentCount: number;
|
||||
fileWatcherCount: number;
|
||||
fileDebouncerCount: number;
|
||||
dirWatcherCount: number;
|
||||
idleTimerCount: number;
|
||||
pendingToolCallsCount: number;
|
||||
@@ -622,7 +634,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
|
||||
return {
|
||||
agentCount: this.agentInfo.size,
|
||||
fileWatcherCount: this.fileWatchers.size,
|
||||
fileDebouncerCount: this.fileDebouncers.size,
|
||||
dirWatcherCount: this.dirWatchers.size,
|
||||
idleTimerCount: this.idleTimers.size,
|
||||
pendingToolCallsCount,
|
||||
@@ -815,7 +827,8 @@ export class SubagentWatcher extends EventEmitter {
|
||||
} else if (content.type === 'text' && content.text) {
|
||||
const text = content.text.trim();
|
||||
if (text.length > 0) {
|
||||
const preview = text.length > TEXT_PREVIEW_LENGTH ? text.substring(0, TEXT_PREVIEW_LENGTH) + '...' : text;
|
||||
const preview =
|
||||
text.length > TEXT_PREVIEW_LENGTH ? text.substring(0, TEXT_PREVIEW_LENGTH) + '...' : text;
|
||||
lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`);
|
||||
}
|
||||
}
|
||||
@@ -860,7 +873,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
const MAX_LEN = SMART_TITLE_MAX_LENGTH;
|
||||
|
||||
// Get first line/sentence
|
||||
let title = text.split('\n')[0].trim();
|
||||
const title = text.split('\n')[0].trim();
|
||||
|
||||
// If already short enough, use it
|
||||
if (title.length <= MAX_LEN) {
|
||||
@@ -932,28 +945,43 @@ export class SubagentWatcher extends EventEmitter {
|
||||
|
||||
// Check cache first (covers burst of simultaneous agent discoveries)
|
||||
const cached = this.parentDescriptionCache.get(cacheKey);
|
||||
if (cached && (Date.now() - cached.timestamp) < CACHE_TTL_MS) {
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||
return cached.descriptions.get(agentId);
|
||||
}
|
||||
|
||||
try {
|
||||
// The parent session's transcript is at: ~/.claude/projects/{projectHash}/{sessionId}.jsonl
|
||||
const transcriptPath = join(CLAUDE_PROJECTS_DIR, projectHash, `${sessionId}.jsonl`);
|
||||
try { await statAsync(transcriptPath); } catch { return undefined; }
|
||||
let fileSize: number;
|
||||
try {
|
||||
const fileStat = await statAsync(transcriptPath);
|
||||
fileSize = fileStat.size;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const content = await readFile(transcriptPath, 'utf8');
|
||||
const lines = content.split('\n').filter((l) => l.trim());
|
||||
// Only read last 16KB — toolUseResult entries are near the end of the transcript
|
||||
const TAIL_BYTES = 16384;
|
||||
const startOffset = Math.max(0, fileSize - TAIL_BYTES);
|
||||
const content = await new Promise<string>((resolve, reject) => {
|
||||
const chunks: string[] = [];
|
||||
const stream = createReadStream(transcriptPath, { start: startOffset, encoding: 'utf8' });
|
||||
stream.on('data', (chunk) => chunks.push(String(chunk)));
|
||||
stream.on('end', () => resolve(chunks.join('')));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
let lines = content.split('\n').filter((l) => l.trim());
|
||||
// If we started mid-file, drop the first partial line
|
||||
if (startOffset > 0 && lines.length > 0) {
|
||||
lines = lines.slice(1);
|
||||
}
|
||||
|
||||
// Parse ALL toolUseResult entries into a Map and cache them
|
||||
const descriptions = new Map<string, string>();
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (
|
||||
entry.type === 'user' &&
|
||||
entry.toolUseResult?.agentId &&
|
||||
entry.toolUseResult?.description
|
||||
) {
|
||||
if (entry.type === 'user' && entry.toolUseResult?.agentId && entry.toolUseResult?.description) {
|
||||
descriptions.set(entry.toolUseResult.agentId, entry.toolUseResult.description);
|
||||
}
|
||||
} catch {
|
||||
@@ -982,7 +1010,11 @@ export class SubagentWatcher extends EventEmitter {
|
||||
let lineCount = 0;
|
||||
let resolved = false;
|
||||
rl.on('line', (line) => {
|
||||
if (resolved || lineCount >= 5) { rl.close(); stream.destroy(); return; }
|
||||
if (resolved || lineCount >= 5) {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
return;
|
||||
}
|
||||
lineCount++;
|
||||
if (!line.trim()) return;
|
||||
try {
|
||||
@@ -1008,8 +1040,12 @@ export class SubagentWatcher extends EventEmitter {
|
||||
// Skip malformed lines
|
||||
}
|
||||
});
|
||||
rl.on('close', () => { if (!resolved) resolve(undefined); });
|
||||
rl.on('error', () => { if (!resolved) resolve(undefined); });
|
||||
rl.on('close', () => {
|
||||
if (!resolved) resolve(undefined);
|
||||
});
|
||||
rl.on('error', () => {
|
||||
if (!resolved) resolve(undefined);
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// Failed to read file
|
||||
@@ -1021,7 +1057,11 @@ export class SubagentWatcher extends EventEmitter {
|
||||
* Scan for all subagent directories (async to avoid blocking event loop)
|
||||
*/
|
||||
private async scanForSubagents(): Promise<void> {
|
||||
try { await statAsync(CLAUDE_PROJECTS_DIR); } catch { return; }
|
||||
try {
|
||||
await statAsync(CLAUDE_PROJECTS_DIR);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const projects = await readdir(CLAUDE_PROJECTS_DIR);
|
||||
@@ -1063,39 +1103,51 @@ export class SubagentWatcher extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch a subagent directory for new/updated files
|
||||
* Watch a subagent directory for new and changed files.
|
||||
* Uses a single directory-level fs.watch() instead of per-file watchers.
|
||||
* On Linux, inotify IN_MODIFY fires for content changes within the directory.
|
||||
*/
|
||||
private async watchSubagentDir(dir: string, projectHash: string, sessionId: string): Promise<void> {
|
||||
if (this.knownSubagentDirs.has(dir)) return;
|
||||
this.knownSubagentDirs.add(dir);
|
||||
|
||||
// Watch existing files (initial scan - skip old files)
|
||||
// Register existing files (initial scan - skip old files)
|
||||
try {
|
||||
const files = await readdir(dir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.jsonl')) {
|
||||
await this.watchAgentFile(join(dir, file), projectHash, sessionId, true);
|
||||
await this.registerAgentFile(join(dir, file), projectHash, sessionId, true);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Watch for new files with debounce to allow content to be written
|
||||
// Single directory watcher handles both new files and file content changes
|
||||
try {
|
||||
const watcher = watch(dir, (_eventType, filename) => {
|
||||
if (filename?.endsWith('.jsonl')) {
|
||||
const filePath = join(dir, filename);
|
||||
// Wait 100ms for file content to be written before processing
|
||||
// Even if file is empty after debounce, we still watch it - the
|
||||
// description retry mechanisms in processEntry and the file change
|
||||
// handler will extract description when content arrives
|
||||
setTimeout(() => {
|
||||
if (existsSync(filePath)) {
|
||||
this.watchAgentFile(filePath, projectHash, sessionId);
|
||||
}
|
||||
}, FILE_CONTENT_DEBOUNCE_MS);
|
||||
}
|
||||
if (!filename?.endsWith('.jsonl')) return;
|
||||
const filePath = join(dir, filename);
|
||||
|
||||
// Clear existing debounce for this file
|
||||
const existing = this.fileDebouncers.get(filePath);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
// Debounce 100ms to batch rapid writes
|
||||
const timer = setTimeout(() => {
|
||||
this.fileDebouncers.delete(filePath);
|
||||
if (!existsSync(filePath)) return;
|
||||
|
||||
if (this.fileAgentContext.has(filePath)) {
|
||||
// Known file — handle content change
|
||||
this.handleFileChange(filePath).catch(() => {});
|
||||
} else {
|
||||
// New file — register it
|
||||
this.registerAgentFile(filePath, projectHash, sessionId).catch(() => {});
|
||||
}
|
||||
}, FILE_CONTENT_DEBOUNCE_MS);
|
||||
|
||||
this.fileDebouncers.set(filePath, timer);
|
||||
});
|
||||
|
||||
// Handle watcher errors to prevent unhandled exceptions
|
||||
@@ -1117,21 +1169,80 @@ export class SubagentWatcher extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch a specific agent transcript file
|
||||
* Handle a file content change for an already-registered agent file.
|
||||
* Tails from last known position, updates info, retries description if missing.
|
||||
*/
|
||||
private async handleFileChange(filePath: string): Promise<void> {
|
||||
const context = this.fileAgentContext.get(filePath);
|
||||
if (!context) return;
|
||||
|
||||
const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', '');
|
||||
const currentPos = this.filePositions.get(filePath) || 0;
|
||||
const newPos = await this.tailFile(filePath, agentId, context.sessionId, currentPos);
|
||||
this.filePositions.set(filePath, newPos);
|
||||
|
||||
// Update info
|
||||
const existingInfo = this.agentInfo.get(agentId);
|
||||
if (existingInfo) {
|
||||
try {
|
||||
const newStat = await statAsync(filePath);
|
||||
existingInfo.lastActivityAt = Date.now();
|
||||
existingInfo.fileSize = newStat.size;
|
||||
existingInfo.status = 'active';
|
||||
} catch {
|
||||
// Stat failed
|
||||
}
|
||||
|
||||
// Retry description extraction if missing (race condition fix)
|
||||
if (!existingInfo.description) {
|
||||
// First try parent transcript (most reliable)
|
||||
let extractedDescription = await this.extractDescriptionFromParentTranscript(
|
||||
existingInfo.projectHash,
|
||||
existingInfo.sessionId,
|
||||
agentId
|
||||
);
|
||||
// Fallback to subagent file
|
||||
if (!extractedDescription) {
|
||||
extractedDescription = await this.extractDescriptionFromFile(filePath);
|
||||
}
|
||||
if (extractedDescription) {
|
||||
// Check if this is an internal agent - if so, remove it
|
||||
if (this.isInternalAgent(extractedDescription)) {
|
||||
this.removeAgent(agentId);
|
||||
return;
|
||||
}
|
||||
existingInfo.description = extractedDescription;
|
||||
this.emit('subagent:updated', existingInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset idle timer
|
||||
this.resetIdleTimer(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a specific agent transcript file (discovery + initial read).
|
||||
* Does NOT create a per-file watcher — the directory watcher handles changes.
|
||||
* @param filePath Path to the agent transcript file
|
||||
* @param projectHash Claude project hash
|
||||
* @param sessionId Claude session ID
|
||||
* @param isInitialScan If true, skip files older than STARTUP_MAX_FILE_AGE_MS
|
||||
*/
|
||||
private async watchAgentFile(filePath: string, projectHash: string, sessionId: string, isInitialScan: boolean = false): Promise<void> {
|
||||
if (this.fileWatchers.has(filePath)) return;
|
||||
private async registerAgentFile(
|
||||
filePath: string,
|
||||
projectHash: string,
|
||||
sessionId: string,
|
||||
isInitialScan: boolean = false
|
||||
): Promise<void> {
|
||||
if (this.fileAgentContext.has(filePath)) return;
|
||||
|
||||
const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', '');
|
||||
|
||||
// Initial info - handle race condition where file may be deleted between discovery and stat
|
||||
let stat;
|
||||
let fileStat;
|
||||
try {
|
||||
stat = await statAsync(filePath);
|
||||
fileStat = await statAsync(filePath);
|
||||
} catch {
|
||||
// File was deleted between discovery and stat - skip this agent
|
||||
return;
|
||||
@@ -1139,7 +1250,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
|
||||
// On initial scan, skip old files to avoid loading stale historical data
|
||||
if (isInitialScan) {
|
||||
const fileAge = Date.now() - stat.mtime.getTime();
|
||||
const fileAge = Date.now() - fileStat.mtime.getTime();
|
||||
if (fileAge > STARTUP_MAX_FILE_AGE_MS) {
|
||||
return; // Skip old files on startup
|
||||
}
|
||||
@@ -1164,12 +1275,12 @@ export class SubagentWatcher extends EventEmitter {
|
||||
sessionId,
|
||||
projectHash,
|
||||
filePath,
|
||||
startedAt: stat.birthtime.toISOString(),
|
||||
lastActivityAt: stat.mtime.getTime(),
|
||||
startedAt: fileStat.birthtime.toISOString(),
|
||||
lastActivityAt: fileStat.mtime.getTime(),
|
||||
status: 'active',
|
||||
toolCallCount: 0,
|
||||
entryCount: 0,
|
||||
fileSize: stat.size,
|
||||
fileSize: fileStat.size,
|
||||
description,
|
||||
};
|
||||
|
||||
@@ -1188,93 +1299,28 @@ export class SubagentWatcher extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Track file context for directory watcher change handling
|
||||
this.fileAgentContext.set(filePath, { projectHash, sessionId });
|
||||
this.agentInfo.set(agentId, info);
|
||||
this.emit('subagent:discovered', info);
|
||||
|
||||
// Read existing content
|
||||
this.tailFile(filePath, agentId, sessionId, 0).then((position) => {
|
||||
this.filePositions.set(filePath, position);
|
||||
}).catch((err) => {
|
||||
// Log but don't throw - non-critical background operation
|
||||
console.warn(`[SubagentWatcher] Failed to read initial content for ${agentId}:`, err);
|
||||
});
|
||||
|
||||
// Watch for changes
|
||||
try {
|
||||
const watcher = watch(filePath, async (eventType) => {
|
||||
if (eventType === 'change') {
|
||||
const currentPos = this.filePositions.get(filePath) || 0;
|
||||
const newPos = await this.tailFile(filePath, agentId, sessionId, currentPos);
|
||||
this.filePositions.set(filePath, newPos);
|
||||
|
||||
// Update info
|
||||
const existingInfo = this.agentInfo.get(agentId);
|
||||
if (existingInfo) {
|
||||
try {
|
||||
const newStat = await statAsync(filePath);
|
||||
existingInfo.lastActivityAt = Date.now();
|
||||
existingInfo.fileSize = newStat.size;
|
||||
existingInfo.status = 'active';
|
||||
} catch {
|
||||
// Stat failed
|
||||
}
|
||||
|
||||
// Retry description extraction if missing (race condition fix)
|
||||
if (!existingInfo.description) {
|
||||
// First try parent transcript (most reliable)
|
||||
let extractedDescription = await this.extractDescriptionFromParentTranscript(
|
||||
existingInfo.projectHash,
|
||||
existingInfo.sessionId,
|
||||
agentId
|
||||
);
|
||||
// Fallback to subagent file
|
||||
if (!extractedDescription) {
|
||||
extractedDescription = await this.extractDescriptionFromFile(filePath);
|
||||
}
|
||||
if (extractedDescription) {
|
||||
// Check if this is an internal agent - if so, remove it
|
||||
if (this.isInternalAgent(extractedDescription)) {
|
||||
this.removeAgent(agentId);
|
||||
return;
|
||||
}
|
||||
existingInfo.description = extractedDescription;
|
||||
this.emit('subagent:updated', existingInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset idle timer
|
||||
this.resetIdleTimer(agentId);
|
||||
}
|
||||
}
|
||||
this.tailFile(filePath, agentId, sessionId, 0)
|
||||
.then((position) => {
|
||||
this.filePositions.set(filePath, position);
|
||||
})
|
||||
.catch((err) => {
|
||||
// Log but don't throw - non-critical background operation
|
||||
console.warn(`[SubagentWatcher] Failed to read initial content for ${agentId}:`, err);
|
||||
});
|
||||
|
||||
// Handle watcher errors to prevent unhandled exceptions
|
||||
// Store handler reference for proper cleanup
|
||||
const errorHandler = (error: Error) => {
|
||||
this.emit('subagent:error', error instanceof Error ? error : new Error(String(error)), agentId);
|
||||
watcher.close();
|
||||
this.fileWatcherErrorHandlers.delete(filePath);
|
||||
this.fileWatchers.delete(filePath);
|
||||
};
|
||||
watcher.on('error', errorHandler);
|
||||
this.fileWatcherErrorHandlers.set(filePath, errorHandler);
|
||||
|
||||
this.fileWatchers.set(filePath, watcher);
|
||||
this.resetIdleTimer(agentId);
|
||||
} catch {
|
||||
// Watch failed
|
||||
}
|
||||
this.resetIdleTimer(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tail a file from a specific position
|
||||
*/
|
||||
private async tailFile(
|
||||
filePath: string,
|
||||
agentId: string,
|
||||
sessionId: string,
|
||||
fromPosition: number
|
||||
): Promise<number> {
|
||||
private async tailFile(filePath: string, agentId: string, sessionId: string, fromPosition: number): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
let position = fromPosition;
|
||||
|
||||
@@ -1337,11 +1383,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
// Check if this is first user message and description is missing
|
||||
if (info && !info.description && entry.type === 'user' && entry.message?.content) {
|
||||
// First try parent transcript (most reliable)
|
||||
let description = await this.extractDescriptionFromParentTranscript(
|
||||
info.projectHash,
|
||||
info.sessionId,
|
||||
agentId
|
||||
);
|
||||
let description = await this.extractDescriptionFromParentTranscript(info.projectHash, info.sessionId, agentId);
|
||||
// Fallback: extract smart title from the prompt content
|
||||
if (!description) {
|
||||
let text: string | undefined;
|
||||
@@ -1378,9 +1420,11 @@ export class SubagentWatcher extends EventEmitter {
|
||||
resultCount: entry.data.resultCount,
|
||||
// Extract hook event info if present
|
||||
hookEvent: entry.data.hookEvent,
|
||||
hookName: entry.data.hookName || (entry.data.hookEvent && entry.data.tool_name
|
||||
? `${entry.data.hookEvent}:${entry.data.tool_name}`
|
||||
: undefined),
|
||||
hookName:
|
||||
entry.data.hookName ||
|
||||
(entry.data.hookEvent && entry.data.tool_name
|
||||
? `${entry.data.hookEvent}:${entry.data.tool_name}`
|
||||
: undefined),
|
||||
};
|
||||
this.emit('subagent:progress', progress);
|
||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||
@@ -1531,8 +1575,8 @@ export class SubagentWatcher extends EventEmitter {
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter(c => c.type === 'text' && c.text)
|
||||
.map(c => c.text)
|
||||
.filter((c) => c.type === 'text' && c.text)
|
||||
.map((c) => c.text)
|
||||
.join('\n');
|
||||
}
|
||||
return '';
|
||||
|
||||
@@ -196,7 +196,9 @@ export class TaskQueue extends EventEmitter {
|
||||
private validateDependencies(taskId: string, dependencies: string[]): void {
|
||||
for (const depId of dependencies) {
|
||||
if (this.wouldCreateCycle(taskId, depId)) {
|
||||
throw new Error(`Circular dependency detected: adding dependency ${depId} to task ${taskId} would create a cycle`);
|
||||
throw new Error(
|
||||
`Circular dependency detected: adding dependency ${depId} to task ${taskId} would create a cycle`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,14 +210,21 @@ export class TaskQueue extends EventEmitter {
|
||||
|
||||
/** Gets the currently running task for a session, if any. */
|
||||
getRunningTaskForSession(sessionId: string): Task | null {
|
||||
return this.getAllTasks().find(
|
||||
(t) => t.isRunning() && t.assignedSessionId === sessionId
|
||||
) || null;
|
||||
return this.getAllTasks().find((t) => t.isRunning() && t.assignedSessionId === sessionId) || null;
|
||||
}
|
||||
|
||||
/** Gets counts of tasks by status (single-pass). */
|
||||
getCount(): { total: number; pending: number; running: number; completed: number; failed: number } {
|
||||
let pending = 0, running = 0, completed = 0, failed = 0;
|
||||
getCount(): {
|
||||
total: number;
|
||||
pending: number;
|
||||
running: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
} {
|
||||
let pending = 0,
|
||||
running = 0,
|
||||
completed = 0,
|
||||
failed = 0;
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.isPending()) pending++;
|
||||
else if (task.isRunning()) running++;
|
||||
@@ -258,7 +267,6 @@ export class TaskQueue extends EventEmitter {
|
||||
this.tasks.clear();
|
||||
return count;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -51,21 +51,13 @@ const MAX_PENDING_TOOL_USES = 100;
|
||||
* Used as fallback when JSON parsing doesn't capture the launch.
|
||||
* Capture group 1: Agent/task type name
|
||||
*/
|
||||
const LAUNCH_PATTERNS = [
|
||||
/Launching\s+(\w+)\s+agent/i,
|
||||
/Starting\s+(\w+)\s+task/i,
|
||||
/Spawning\s+(\w+)\s+agent/i,
|
||||
];
|
||||
const LAUNCH_PATTERNS = [/Launching\s+(\w+)\s+agent/i, /Starting\s+(\w+)\s+task/i, /Spawning\s+(\w+)\s+agent/i];
|
||||
|
||||
/**
|
||||
* Patterns that indicate a task has completed.
|
||||
* Used as fallback when JSON parsing doesn't capture the result.
|
||||
*/
|
||||
const COMPLETE_PATTERNS = [
|
||||
/Task\s+completed/i,
|
||||
/Agent\s+finished/i,
|
||||
/Background\s+task\s+done/i,
|
||||
];
|
||||
const COMPLETE_PATTERNS = [/Task\s+completed/i, /Agent\s+finished/i, /Background\s+task\s+done/i];
|
||||
|
||||
// ========== Type Definitions ==========
|
||||
|
||||
@@ -218,7 +210,10 @@ export class TaskTracker extends EventEmitter {
|
||||
private taskStack: string[] = [];
|
||||
|
||||
/** Pending tool_use blocks waiting for results (with timestamp for cleanup) */
|
||||
private pendingToolUses: Map<string, { description: string; subagentType: string; parentId: string | null; createdAt: number }> = new Map();
|
||||
private pendingToolUses: Map<
|
||||
string,
|
||||
{ description: string; subagentType: string; parentId: string | null; createdAt: number }
|
||||
> = new Map();
|
||||
|
||||
/**
|
||||
* Creates a new TaskTracker instance.
|
||||
@@ -316,7 +311,12 @@ export class TaskTracker extends EventEmitter {
|
||||
const parentId = this.taskStack.length > 0 ? this.taskStack[this.taskStack.length - 1] : null;
|
||||
|
||||
// Store pending tool use - task starts when we see activity
|
||||
this.pendingToolUses.set(toolUseId, { description, subagentType, parentId, createdAt: Date.now() });
|
||||
this.pendingToolUses.set(toolUseId, {
|
||||
description,
|
||||
subagentType,
|
||||
parentId,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
// Clean up old pending entries to prevent unbounded growth
|
||||
this.cleanupOldPendingToolUses();
|
||||
@@ -361,9 +361,7 @@ export class TaskTracker extends EventEmitter {
|
||||
if (task) {
|
||||
task.status = block.is_error ? 'failed' : 'completed';
|
||||
task.endTime = Date.now();
|
||||
task.output = typeof block.content === 'string'
|
||||
? block.content
|
||||
: JSON.stringify(block.content);
|
||||
task.output = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
|
||||
|
||||
// Remove from stack
|
||||
const stackIndex = this.taskStack.indexOf(toolUseId);
|
||||
@@ -610,13 +608,21 @@ export class TaskTracker extends EventEmitter {
|
||||
* - failed: Tasks that ended with errors
|
||||
*/
|
||||
getStats(): { total: number; running: number; completed: number; failed: number } {
|
||||
let running = 0, completed = 0, failed = 0;
|
||||
let running = 0,
|
||||
completed = 0,
|
||||
failed = 0;
|
||||
|
||||
for (const task of this.tasks.values()) {
|
||||
switch (task.status) {
|
||||
case 'running': running++; break;
|
||||
case 'completed': completed++; break;
|
||||
case 'failed': failed++; break;
|
||||
case 'running':
|
||||
running++;
|
||||
break;
|
||||
case 'completed':
|
||||
completed++;
|
||||
break;
|
||||
case 'failed':
|
||||
failed++;
|
||||
break;
|
||||
default:
|
||||
assertNever(task.status, `Unhandled BackgroundTask status: ${task.status}`);
|
||||
}
|
||||
|
||||
@@ -13,12 +13,14 @@ import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { watch as chokidarWatch, type FSWatcher as ChokidarWatcher } from 'chokidar';
|
||||
|
||||
import type { TeamConfig, TeamMember, TeamTask, InboxMessage } from './types.js';
|
||||
import { LRUMap } from './utils/lru-map.js';
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
const POLL_INTERVAL_MS = 30000;
|
||||
const MAX_CACHED_TEAMS = 50;
|
||||
const MAX_CACHED_TASKS = 200;
|
||||
|
||||
@@ -37,6 +39,8 @@ export class TeamWatcher extends EventEmitter {
|
||||
private inboxMtimes: Map<string, number> = new Map();
|
||||
// Reverse index: sessionId → teamName for O(1) lookup
|
||||
private sessionToTeam: Map<string, string> = new Map();
|
||||
private teamsWatcher: ChokidarWatcher | null = null;
|
||||
private tasksWatcher: ChokidarWatcher | null = null;
|
||||
|
||||
constructor(teamsDir?: string, tasksDir?: string) {
|
||||
super();
|
||||
@@ -49,9 +53,60 @@ export class TeamWatcher extends EventEmitter {
|
||||
if (this.pollTimer) return;
|
||||
this.poll();
|
||||
this.pollTimer = setInterval(() => this.poll(), POLL_INTERVAL_MS);
|
||||
this.setupFsWatchers();
|
||||
}
|
||||
|
||||
private setupFsWatchers(): void {
|
||||
try {
|
||||
this.teamsWatcher = chokidarWatch(this.teamsDir, {
|
||||
depth: 2,
|
||||
awaitWriteFinish: { stabilityThreshold: 200 },
|
||||
ignored: /\.lock/,
|
||||
ignoreInitial: true,
|
||||
persistent: false,
|
||||
});
|
||||
|
||||
const teamsHandler = () => this.pollAsync().catch(() => {});
|
||||
this.teamsWatcher.on('add', teamsHandler);
|
||||
this.teamsWatcher.on('change', teamsHandler);
|
||||
this.teamsWatcher.on('unlink', teamsHandler);
|
||||
this.teamsWatcher.on('unlinkDir', teamsHandler);
|
||||
this.teamsWatcher.on('error', (err) => {
|
||||
console.warn('[TeamWatcher] chokidar teams watcher error:', err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[TeamWatcher] Failed to set up teams chokidar watcher, relying on polling:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
this.tasksWatcher = chokidarWatch(this.tasksDir, {
|
||||
depth: 1,
|
||||
awaitWriteFinish: { stabilityThreshold: 200 },
|
||||
ignored: /\.lock/,
|
||||
ignoreInitial: true,
|
||||
persistent: false,
|
||||
});
|
||||
|
||||
this.tasksWatcher.on('add', () => this.pollTasks().catch(() => {}));
|
||||
this.tasksWatcher.on('change', () => this.pollTasks().catch(() => {}));
|
||||
this.tasksWatcher.on('error', (err) => {
|
||||
console.warn('[TeamWatcher] chokidar tasks watcher error:', err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[TeamWatcher] Failed to set up tasks chokidar watcher, relying on polling:', err);
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Close chokidar watchers
|
||||
if (this.teamsWatcher) {
|
||||
this.teamsWatcher.close().catch(() => {});
|
||||
this.teamsWatcher = null;
|
||||
}
|
||||
if (this.tasksWatcher) {
|
||||
this.tasksWatcher.close().catch(() => {});
|
||||
this.tasksWatcher = null;
|
||||
}
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
@@ -86,13 +141,13 @@ export class TeamWatcher extends EventEmitter {
|
||||
getTeamTasks(teamName: string): TeamTask[] {
|
||||
const tasks = this.teamTasks.get(teamName);
|
||||
if (!tasks) return [];
|
||||
return tasks.filter(t => !t.metadata?._internal);
|
||||
return tasks.filter((t) => !t.metadata?._internal);
|
||||
}
|
||||
|
||||
/** Count active (non-completed) tasks for a team */
|
||||
getActiveTaskCount(teamName: string): number {
|
||||
const tasks = this.getTeamTasks(teamName);
|
||||
return tasks.filter(t => t.status !== 'completed').length;
|
||||
return tasks.filter((t) => t.status !== 'completed').length;
|
||||
}
|
||||
|
||||
/** Get inbox messages for a team member (or all members) */
|
||||
@@ -108,9 +163,7 @@ export class TeamWatcher extends EventEmitter {
|
||||
messages.push(...msgs);
|
||||
}
|
||||
}
|
||||
return messages.sort((a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
return messages.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||
}
|
||||
|
||||
/** Check if a session has active teammates (for idle detection) */
|
||||
@@ -119,7 +172,7 @@ export class TeamWatcher extends EventEmitter {
|
||||
if (!team) return false;
|
||||
|
||||
// Check if any non-lead members exist (they are active by definition while present)
|
||||
const teammates = team.members.filter(m => m.agentType !== 'team-lead');
|
||||
const teammates = team.members.filter((m) => m.agentType !== 'team-lead');
|
||||
if (teammates.length === 0) return false;
|
||||
|
||||
// Check if team has active (non-completed) tasks
|
||||
@@ -132,19 +185,17 @@ export class TeamWatcher extends EventEmitter {
|
||||
const team = this.teams.get(teamName);
|
||||
if (!team) return [];
|
||||
|
||||
return team.members
|
||||
.filter((m): m is TeamMember & { tmuxPaneId: string } =>
|
||||
m.agentType !== 'team-lead' &&
|
||||
!!m.tmuxPaneId &&
|
||||
m.tmuxPaneId !== 'in-process'
|
||||
);
|
||||
return team.members.filter(
|
||||
(m): m is TeamMember & { tmuxPaneId: string } =>
|
||||
m.agentType !== 'team-lead' && !!m.tmuxPaneId && m.tmuxPaneId !== 'in-process'
|
||||
);
|
||||
}
|
||||
|
||||
/** Get count of active teammates for a session */
|
||||
getActiveTeammateCount(sessionId: string): number {
|
||||
const team = this.getTeamForSession(sessionId);
|
||||
if (!team) return 0;
|
||||
return team.members.filter(m => m.agentType !== 'team-lead').length;
|
||||
return team.members.filter((m) => m.agentType !== 'team-lead').length;
|
||||
}
|
||||
|
||||
// ========== Private Methods ==========
|
||||
@@ -257,7 +308,7 @@ export class TeamWatcher extends EventEmitter {
|
||||
|
||||
let taskFiles: string[];
|
||||
try {
|
||||
taskFiles = (await readdir(teamTaskDir)).filter(f => f.endsWith('.json') && f !== '.lock');
|
||||
taskFiles = (await readdir(teamTaskDir)).filter((f) => f.endsWith('.json') && f !== '.lock');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
@@ -285,7 +336,7 @@ export class TeamWatcher extends EventEmitter {
|
||||
|
||||
let inboxFiles: string[];
|
||||
try {
|
||||
inboxFiles = (await readdir(inboxDir)).filter(f => f.endsWith('.json'));
|
||||
inboxFiles = (await readdir(inboxDir)).filter((f) => f.endsWith('.json'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
@@ -314,7 +365,7 @@ export class TeamWatcher extends EventEmitter {
|
||||
this.inboxCache.set(cacheKey, messages);
|
||||
|
||||
// Emit new messages — compare by timestamp to handle deletions/reordering
|
||||
const prevTimestamps = new Set(previous?.map(m => m.timestamp) || []);
|
||||
const prevTimestamps = new Set(previous?.map((m) => m.timestamp) || []);
|
||||
for (const msg of messages) {
|
||||
if (!prevTimestamps.has(msg.timestamp)) {
|
||||
this.emit('inboxMessage', { teamName, member: memberName, message: msg });
|
||||
|
||||
@@ -30,10 +30,25 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
||||
import { writeFile, rename } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { ProcessStats, PersistedRespawnConfig, getErrorMessage, DEFAULT_NICE_CONFIG, type PaneInfo, type ClaudeMode, type SessionMode, type OpenCodeConfig } from './types.js';
|
||||
import {
|
||||
ProcessStats,
|
||||
PersistedRespawnConfig,
|
||||
getErrorMessage,
|
||||
DEFAULT_NICE_CONFIG,
|
||||
type PaneInfo,
|
||||
type ClaudeMode,
|
||||
type SessionMode,
|
||||
type OpenCodeConfig,
|
||||
} from './types.js';
|
||||
import { wrapWithNice } from './utils/nice-wrapper.js';
|
||||
import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js';
|
||||
import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats, CreateSessionOptions, RespawnPaneOptions } from './mux-interface.js';
|
||||
import type {
|
||||
TerminalMultiplexer,
|
||||
MuxSession,
|
||||
MuxSessionWithStats,
|
||||
CreateSessionOptions,
|
||||
RespawnPaneOptions,
|
||||
} from './mux-interface.js';
|
||||
|
||||
// Claude CLI PATH resolution — shared utility
|
||||
import { findClaudeDir } from './utils/claude-cli-resolver.js';
|
||||
@@ -103,11 +118,23 @@ function isValidMuxName(name: string): boolean {
|
||||
* Prevents command injection via malformed paths.
|
||||
*/
|
||||
function isValidPath(path: string): boolean {
|
||||
if (path.includes(';') || path.includes('&') || path.includes('|') ||
|
||||
path.includes('$') || path.includes('`') || path.includes('(') ||
|
||||
path.includes(')') || path.includes('{') || path.includes('}') ||
|
||||
path.includes('<') || path.includes('>') || path.includes("'") ||
|
||||
path.includes('"') || path.includes('\n') || path.includes('\r')) {
|
||||
if (
|
||||
path.includes(';') ||
|
||||
path.includes('&') ||
|
||||
path.includes('|') ||
|
||||
path.includes('$') ||
|
||||
path.includes('`') ||
|
||||
path.includes('(') ||
|
||||
path.includes(')') ||
|
||||
path.includes('{') ||
|
||||
path.includes('}') ||
|
||||
path.includes('<') ||
|
||||
path.includes('>') ||
|
||||
path.includes("'") ||
|
||||
path.includes('"') ||
|
||||
path.includes('\n') ||
|
||||
path.includes('\r')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (path.includes('..')) {
|
||||
@@ -177,7 +204,7 @@ function buildSpawnCommand(options: {
|
||||
}): string {
|
||||
if (options.mode === 'claude') {
|
||||
// Validate model to prevent command injection
|
||||
const safeModel = (options.model && /^[a-zA-Z0-9._-]+$/.test(options.model)) ? options.model : undefined;
|
||||
const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined;
|
||||
const modelFlag = safeModel ? ` --model ${safeModel}` : '';
|
||||
return `claude${buildClaudePermissionFlags(options.claudeMode, options.allowedTools)} --session-id "${options.sessionId}"${modelFlag}`;
|
||||
}
|
||||
@@ -204,7 +231,9 @@ function setOpenCodeEnvVars(muxName: string): void {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch { /* Non-critical — key may not be needed */ }
|
||||
} catch {
|
||||
/* Non-critical — key may not be needed */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,7 +254,9 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
|
||||
const existing = JSON.parse(config.configContent) as Record<string, unknown>;
|
||||
Object.assign(permConfig, existing);
|
||||
permConfig.permission = { '*': 'allow' };
|
||||
} catch { /* invalid JSON, use default permConfig */ }
|
||||
} catch {
|
||||
/* invalid JSON, use default permConfig */
|
||||
}
|
||||
}
|
||||
jsonContent = JSON.stringify(permConfig);
|
||||
} else if (config.configContent) {
|
||||
@@ -247,7 +278,9 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch { /* Non-critical */ }
|
||||
} catch {
|
||||
/* Non-critical */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,7 +427,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
|
||||
const envExportsStr = envExports.join(' && ');
|
||||
|
||||
const baseCmd = buildSpawnCommand({ mode, sessionId, model, claudeMode, allowedTools, openCodeConfig });
|
||||
const baseCmd = buildSpawnCommand({
|
||||
mode,
|
||||
sessionId,
|
||||
model,
|
||||
claudeMode,
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
});
|
||||
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
const cmd = wrapWithNice(baseCmd, config);
|
||||
@@ -412,15 +452,22 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// (Production uses systemd which has a clean env, but dev/test may be nested.)
|
||||
const cleanEnv = { ...process.env };
|
||||
delete cleanEnv.TMUX;
|
||||
execSync(
|
||||
`tmux new-session -ds "${muxName}" -c "${workingDir}" -x 120 -y 40`,
|
||||
{ cwd: workingDir, timeout: EXEC_TIMEOUT_MS, stdio: 'ignore', env: cleanEnv }
|
||||
);
|
||||
execSync(`tmux new-session -ds "${muxName}" -c "${workingDir}" -x 120 -y 40`, {
|
||||
cwd: workingDir,
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
env: cleanEnv,
|
||||
});
|
||||
|
||||
// Set remain-on-exit now that the server is running — must be before respawn-pane
|
||||
try {
|
||||
execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS, stdio: 'ignore' });
|
||||
} catch { /* Non-critical */ }
|
||||
execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
} catch {
|
||||
/* Non-critical */
|
||||
}
|
||||
|
||||
// For OpenCode: set sensitive env vars and config via tmux setenv
|
||||
// (not visible in ps output or tmux history, inherited by panes)
|
||||
@@ -430,13 +477,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
// Replace the shell with the actual command (no echo in terminal)
|
||||
execSync(
|
||||
`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`,
|
||||
{ timeout: EXEC_TIMEOUT_MS, stdio: 'ignore' }
|
||||
);
|
||||
execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
// Wait for tmux session to be queryable
|
||||
await new Promise(resolve => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||
|
||||
// Non-critical tmux config — run in parallel to avoid blocking event loop.
|
||||
// These configure UX niceties (no status bar, true color).
|
||||
@@ -445,18 +492,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const configPromises: Promise<void>[] = [
|
||||
// Disable tmux status bar — Codeman's web UI provides session info
|
||||
execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
|
||||
.then(() => {}).catch(() => { /* Non-critical — session still works with status bar */ }),
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
/* Non-critical — session still works with status bar */
|
||||
}),
|
||||
// Override global remain-on-exit with session-level setting
|
||||
execAsync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
|
||||
.then(() => {}).catch(() => { /* Already set globally as fallback */ }),
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
/* Already set globally as fallback */
|
||||
}),
|
||||
];
|
||||
|
||||
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
|
||||
if (!this.trueColorConfigured) {
|
||||
configPromises.push(
|
||||
execAsync(`tmux set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
|
||||
.then(() => { this.trueColorConfigured = true; })
|
||||
.catch(() => { /* Non-critical — colors limited to 256 */ })
|
||||
.then(() => {
|
||||
this.trueColorConfigured = true;
|
||||
})
|
||||
.catch(() => {
|
||||
/* Non-critical — colors limited to 256 */
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -467,7 +524,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// Get the PID of the pane process (retry for tmux server cold-start)
|
||||
let pid = this.getPanePid(muxName);
|
||||
for (let i = 0; !pid && i < GET_PID_MAX_RETRIES; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, GET_PID_RETRY_MS));
|
||||
await new Promise((resolve) => setTimeout(resolve, GET_PID_RETRY_MS));
|
||||
pid = this.getPanePid(muxName);
|
||||
}
|
||||
if (!pid) {
|
||||
@@ -507,10 +564,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
try {
|
||||
const output = execSync(
|
||||
`tmux display-message -t "${muxName}" -p '#{pane_pid}'`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_pid}'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
const pid = parseInt(output, 10);
|
||||
return Number.isNaN(pid) ? null : pid;
|
||||
} catch {
|
||||
@@ -533,10 +590,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (IS_TEST_MODE) return false;
|
||||
if (!isValidMuxName(muxName)) return false;
|
||||
try {
|
||||
const output = execSync(
|
||||
`tmux display-message -t "${muxName}" -p '#{pane_dead}'`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_dead}'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
return output === '1';
|
||||
} catch {
|
||||
return false;
|
||||
@@ -578,7 +635,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE');
|
||||
const envExportsStr = envExports.join(' && ');
|
||||
|
||||
const baseCmd = buildSpawnCommand({ mode, sessionId, model, claudeMode, allowedTools, openCodeConfig });
|
||||
const baseCmd = buildSpawnCommand({
|
||||
mode,
|
||||
sessionId,
|
||||
model,
|
||||
claudeMode,
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
});
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
const cmd = wrapWithNice(baseCmd, config);
|
||||
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
|
||||
@@ -590,12 +654,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
setOpenCodeConfigContent(muxName, openCodeConfig);
|
||||
}
|
||||
|
||||
await execAsync(
|
||||
`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`,
|
||||
{ timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
// Wait for the respawned process to start
|
||||
await new Promise(resolve => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||
const pid = this.getPanePid(muxName);
|
||||
if (pid) session.pid = pid;
|
||||
return pid;
|
||||
@@ -628,7 +691,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (output) {
|
||||
for (const childPid of output.split('\n').map(p => parseInt(p, 10)).filter(p => !Number.isNaN(p))) {
|
||||
for (const childPid of output
|
||||
.split('\n')
|
||||
.map((p) => parseInt(p, 10))
|
||||
.filter((p) => !Number.isNaN(p))) {
|
||||
pids.push(childPid);
|
||||
pids.push(...this.getChildPids(childPid));
|
||||
}
|
||||
@@ -655,14 +721,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const checkInterval = 100;
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
const aliveCount = pids.filter(pid => this.isProcessAlive(pid)).length;
|
||||
const aliveCount = pids.filter((pid) => this.isProcessAlive(pid)).length;
|
||||
if (aliveCount === 0) {
|
||||
return true;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
||||
await new Promise((resolve) => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
const stillAlive = pids.filter(pid => this.isProcessAlive(pid));
|
||||
const stillAlive = pids.filter((pid) => this.isProcessAlive(pid));
|
||||
if (stillAlive.length > 0) {
|
||||
console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
|
||||
}
|
||||
@@ -717,7 +783,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, TMUX_KILL_WAIT_MS));
|
||||
await new Promise((resolve) => setTimeout(resolve, TMUX_KILL_WAIT_MS));
|
||||
|
||||
childPids = this.getChildPids(currentPid);
|
||||
for (const childPid of childPids) {
|
||||
@@ -735,7 +801,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (this.isProcessAlive(currentPid)) {
|
||||
try {
|
||||
process.kill(-currentPid, 'SIGTERM');
|
||||
await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS));
|
||||
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS));
|
||||
if (this.isProcessAlive(currentPid)) {
|
||||
process.kill(-currentPid, 'SIGKILL');
|
||||
}
|
||||
@@ -829,10 +895,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
// Discover unknown codeman sessions
|
||||
try {
|
||||
const output = execSync(
|
||||
"tmux list-sessions -F '#{session_name}' 2>/dev/null || true",
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
const output = execSync("tmux list-sessions -F '#{session_name}' 2>/dev/null || true", {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
|
||||
for (const line of output.split('\n')) {
|
||||
const sessionName = line.trim();
|
||||
@@ -890,26 +956,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
try {
|
||||
const psOutput = execSync(
|
||||
`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
const psOutput = execSync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
|
||||
const [rss, cpu] = psOutput.split(/\s+/).map(x => parseFloat(x) || 0);
|
||||
const [rss, cpu] = psOutput.split(/\s+/).map((x) => parseFloat(x) || 0);
|
||||
|
||||
let childCount = 0;
|
||||
try {
|
||||
const childOutput = execSync(
|
||||
`pgrep -P ${session.pid} | wc -l`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
const childOutput = execSync(`pgrep -P ${session.pid} | wc -l`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
childCount = parseInt(childOutput, 10) || 0;
|
||||
} catch {
|
||||
// No children or command failed
|
||||
}
|
||||
|
||||
return {
|
||||
memoryMB: Math.round(rss / 1024 * 10) / 10,
|
||||
memoryMB: Math.round((rss / 1024) * 10) / 10,
|
||||
cpuPercent: Math.round(cpu * 10) / 10,
|
||||
childCount,
|
||||
updatedAt: Date.now(),
|
||||
@@ -921,7 +987,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
async getSessionsWithStats(): Promise<MuxSessionWithStats[]> {
|
||||
if (IS_TEST_MODE) {
|
||||
return Array.from(this.sessions.values()).map(s => ({
|
||||
return Array.from(this.sessions.values()).map((s) => ({
|
||||
...s,
|
||||
stats: { memoryMB: 0, cpuPercent: 0, childCount: 0, updatedAt: Date.now() },
|
||||
}));
|
||||
@@ -932,7 +998,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessionPids = sessions.map(s => s.pid);
|
||||
const sessionPids = sessions.map((s) => s.pid);
|
||||
const statsMap = new Map<number, ProcessStats>();
|
||||
|
||||
try {
|
||||
@@ -941,7 +1007,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
const pgrepOutput = execSync(
|
||||
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}
|
||||
).trim();
|
||||
|
||||
for (const line of pgrepOutput.split('\n')) {
|
||||
@@ -950,8 +1019,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (!Number.isNaN(sessionPid)) {
|
||||
const children = (childrenStr || '')
|
||||
.split(',')
|
||||
.map(s => parseInt(s.trim(), 10))
|
||||
.filter(n => !Number.isNaN(n) && n > 0);
|
||||
.map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n) && n > 0);
|
||||
descendantMap.set(sessionPid, children);
|
||||
}
|
||||
}
|
||||
@@ -967,10 +1036,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// Step 3: Single ps call
|
||||
const pidArray = Array.from(allPids);
|
||||
if (pidArray.length > 0) {
|
||||
const psOutput = execSync(
|
||||
`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
const psOutput = execSync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
|
||||
const processStats = new Map<number, { rss: number; cpu: number }>();
|
||||
for (const line of psOutput.split('\n')) {
|
||||
@@ -1002,7 +1071,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
statsMap.set(sessionPid, {
|
||||
memoryMB: Math.round(totalRss / 1024 * 10) / 10,
|
||||
memoryMB: Math.round((totalRss / 1024) * 10) / 10,
|
||||
cpuPercent: Math.round(totalCpu * 10) / 10,
|
||||
childCount: children.length,
|
||||
updatedAt: Date.now(),
|
||||
@@ -1011,7 +1080,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
} catch {
|
||||
// Fall back to individual queries
|
||||
const statsPromises = sessions.map(session => this.getProcessStats(session.sessionId));
|
||||
const statsPromises = sessions.map((session) => this.getProcessStats(session.sessionId));
|
||||
const results = await Promise.allSettled(statsPromises);
|
||||
return sessions.map((session, i) => ({
|
||||
...session,
|
||||
@@ -1019,7 +1088,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}));
|
||||
}
|
||||
|
||||
return sessions.map(session => ({
|
||||
return sessions.map((session) => ({
|
||||
...session,
|
||||
stats: statsMap.get(session.pid) || undefined,
|
||||
}));
|
||||
@@ -1145,7 +1214,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
async sendInput(sessionId: string, input: string): Promise<boolean> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.error(`[TmuxManager] sendInput failed: no session found for ${sessionId}. Known: ${Array.from(this.sessions.keys()).join(', ')}`);
|
||||
console.error(
|
||||
`[TmuxManager] sendInput failed: no session found for ${sessionId}. Known: ${Array.from(this.sessions.keys()).join(', ')}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1154,7 +1225,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`[TmuxManager] sendInput to ${session.muxName}, input length: ${input.length}, hasCarriageReturn: ${input.includes('\r')}`);
|
||||
console.log(
|
||||
`[TmuxManager] sendInput to ${session.muxName}, input length: ${input.length}, hasCarriageReturn: ${input.includes('\r')}`
|
||||
);
|
||||
|
||||
if (!isValidMuxName(session.muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in sendInput:', session.muxName);
|
||||
@@ -1170,27 +1243,23 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// Ink (Claude CLI's terminal framework) needs them split — sending both in a
|
||||
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline
|
||||
// character in the input buffer rather than as form submission.
|
||||
await execAsync(
|
||||
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`,
|
||||
{ timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
await execAsync(
|
||||
`tmux send-keys -t "${session.muxName}" Enter`,
|
||||
{ timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} else if (textPart) {
|
||||
// Text only, no Enter
|
||||
await execAsync(
|
||||
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`,
|
||||
{ timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} else if (hasCarriageReturn) {
|
||||
// Enter only
|
||||
await execAsync(
|
||||
`tmux send-keys -t "${session.muxName}" Enter`,
|
||||
{ timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -1215,10 +1284,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(
|
||||
`tmux set-option -t "${muxName}" mouse on`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
execSync(`tmux set-option -t "${muxName}" mouse on`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
console.log(`[TmuxManager] Mouse mode ON for ${muxName}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -1239,10 +1308,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(
|
||||
`tmux set-option -t "${muxName}" mouse off`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
execSync(`tmux set-option -t "${muxName}" mouse off`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
console.log(`[TmuxManager] Mouse mode OFF for ${muxName}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -1283,16 +1352,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
|
||||
return output.split('\n').map(line => {
|
||||
const [paneId, indexStr, pidStr, widthStr, heightStr] = line.split(':');
|
||||
return {
|
||||
paneId,
|
||||
paneIndex: parseInt(indexStr, 10),
|
||||
panePid: parseInt(pidStr, 10),
|
||||
width: parseInt(widthStr, 10),
|
||||
height: parseInt(heightStr, 10),
|
||||
};
|
||||
}).filter(p => !Number.isNaN(p.paneIndex));
|
||||
return output
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const [paneId, indexStr, pidStr, widthStr, heightStr] = line.split(':');
|
||||
return {
|
||||
paneId,
|
||||
paneIndex: parseInt(indexStr, 10),
|
||||
panePid: parseInt(pidStr, 10),
|
||||
width: parseInt(widthStr, 10),
|
||||
height: parseInt(heightStr, 10),
|
||||
};
|
||||
})
|
||||
.filter((p) => !Number.isNaN(p.paneIndex));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -1314,33 +1386,31 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
// Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
|
||||
const target = paneTarget.startsWith('%')
|
||||
? `${muxName}.${paneTarget}`
|
||||
: `${muxName}.%${paneTarget}`;
|
||||
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||||
|
||||
try {
|
||||
const hasCarriageReturn = input.includes('\r');
|
||||
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
|
||||
|
||||
if (textPart && hasCarriageReturn) {
|
||||
execSync(
|
||||
`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
execSync(
|
||||
`tmux send-keys -t ${shellescape(target)} Enter`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} else if (textPart) {
|
||||
execSync(
|
||||
`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} else if (hasCarriageReturn) {
|
||||
execSync(
|
||||
`tmux send-keys -t ${shellescape(target)} Enter`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -1365,15 +1435,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = paneTarget.startsWith('%')
|
||||
? `${muxName}.${paneTarget}`
|
||||
: `${muxName}.%${paneTarget}`;
|
||||
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||||
|
||||
try {
|
||||
return execSync(
|
||||
`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
return execSync(`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to capture pane buffer:', err);
|
||||
return null;
|
||||
@@ -1399,15 +1467,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = paneTarget.startsWith('%')
|
||||
? `${muxName}.${paneTarget}`
|
||||
: `${muxName}.%${paneTarget}`;
|
||||
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||||
|
||||
try {
|
||||
execSync(
|
||||
`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
execSync(`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to start pipe-pane:', err);
|
||||
@@ -1429,15 +1495,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = paneTarget.startsWith('%')
|
||||
? `${muxName}.${paneTarget}`
|
||||
: `${muxName}.%${paneTarget}`;
|
||||
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||||
|
||||
try {
|
||||
execSync(
|
||||
`tmux pipe-pane -t ${shellescape(target)}`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
);
|
||||
execSync(`tmux pipe-pane -t ${shellescape(target)}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to stop pipe-pane:', err);
|
||||
|
||||
@@ -86,12 +86,7 @@ const POLL_INTERVAL_MS = 1000;
|
||||
const MAX_MESSAGE_LENGTH = 500;
|
||||
|
||||
/** Patterns that indicate plan mode / approval prompt */
|
||||
const PLAN_MODE_PATTERNS = [
|
||||
/ExitPlanMode/i,
|
||||
/AskUserQuestion/i,
|
||||
/Ready for user approval/i,
|
||||
/approve.*plan/i,
|
||||
];
|
||||
const PLAN_MODE_PATTERNS = [/ExitPlanMode/i, /AskUserQuestion/i, /Ready for user approval/i, /approve.*plan/i];
|
||||
|
||||
// ========== TranscriptWatcher Class ==========
|
||||
|
||||
@@ -410,12 +405,13 @@ export class TranscriptWatcher extends EventEmitter {
|
||||
if (entry.type !== 'assistant' || !entry.message?.content) return;
|
||||
|
||||
const content = entry.message.content;
|
||||
const textToCheck = typeof content === 'string'
|
||||
? content
|
||||
: content
|
||||
.filter((b): b is { type: 'text'; text: string } => b.type === 'text' && !!b.text)
|
||||
.map(b => b.text)
|
||||
.join(' ');
|
||||
const textToCheck =
|
||||
typeof content === 'string'
|
||||
? content
|
||||
: content
|
||||
.filter((b): b is { type: 'text'; text: string } => b.type === 'text' && !!b.text)
|
||||
.map((b) => b.text)
|
||||
.join(' ');
|
||||
|
||||
// Also check for tool_use with ExitPlanMode or AskUserQuestion
|
||||
if (Array.isArray(content)) {
|
||||
|
||||
@@ -112,7 +112,10 @@ export class TunnelManager extends EventEmitter {
|
||||
|
||||
const binary = this.resolveCloudflared();
|
||||
if (!binary) {
|
||||
this.emit('error', 'cloudflared not found. Install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/');
|
||||
this.emit(
|
||||
'error',
|
||||
'cloudflared not found. Install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,16 +80,16 @@ export type TddPhase = 'setup' | 'test' | 'impl' | 'verify' | 'review';
|
||||
|
||||
/** Types of session lifecycle events recorded to the audit log */
|
||||
export type LifecycleEventType =
|
||||
| 'created' // Session object created
|
||||
| 'started' // PTY process launched (interactive/shell/prompt)
|
||||
| 'exit' // PTY process exited (with exit code)
|
||||
| 'deleted' // cleanupSession() called — session removed
|
||||
| 'detached' // Server shutdown — PTY left alive in tmux for recovery
|
||||
| 'recovered' // Session restored from tmux on server restart
|
||||
| 'stale_cleaned' // Removed from state.json by cleanupStaleSessions()
|
||||
| 'mux_died' // tmux session died (detected by reconciliation)
|
||||
| 'server_started' // Server started (marker for restart detection)
|
||||
| 'server_stopped'; // Server shutting down
|
||||
| 'created' // Session object created
|
||||
| 'started' // PTY process launched (interactive/shell/prompt)
|
||||
| 'exit' // PTY process exited (with exit code)
|
||||
| 'deleted' // cleanupSession() called — session removed
|
||||
| 'detached' // Server shutdown — PTY left alive in tmux for recovery
|
||||
| 'recovered' // Session restored from tmux on server restart
|
||||
| 'stale_cleaned' // Removed from state.json by cleanupStaleSessions()
|
||||
| 'mux_died' // tmux session died (detected by reconciliation)
|
||||
| 'server_started' // Server started (marker for restart detection)
|
||||
| 'server_stopped'; // Server shutting down
|
||||
|
||||
/** A single entry in the session lifecycle audit log */
|
||||
export interface LifecycleEntry {
|
||||
@@ -145,15 +145,7 @@ export interface SessionConfig {
|
||||
/**
|
||||
* Available session colors for visual differentiation
|
||||
*/
|
||||
export type SessionColor =
|
||||
| 'default'
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'yellow'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'purple'
|
||||
| 'pink';
|
||||
export type SessionColor = 'default' | 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'pink';
|
||||
|
||||
/**
|
||||
* Current state of a session
|
||||
@@ -472,11 +464,11 @@ export interface RespawnConfig {
|
||||
* Outcome of a respawn cycle
|
||||
*/
|
||||
export type CycleOutcome =
|
||||
| 'success' // Cycle completed normally
|
||||
| 'stuck_recovery' // Stuck-state recovery triggered
|
||||
| 'blocked' // Blocked by circuit breaker or exit signal
|
||||
| 'error' // Error during cycle
|
||||
| 'cancelled'; // Cancelled (e.g., controller stopped)
|
||||
| 'success' // Cycle completed normally
|
||||
| 'stuck_recovery' // Stuck-state recovery triggered
|
||||
| 'blocked' // Blocked by circuit breaker or exit signal
|
||||
| 'error' // Error during cycle
|
||||
| 'cancelled'; // Cancelled (e.g., controller stopped)
|
||||
|
||||
/**
|
||||
* Metrics for a single respawn cycle.
|
||||
@@ -675,7 +667,7 @@ export enum ApiErrorCode {
|
||||
/**
|
||||
* User-friendly error messages for each error code
|
||||
*/
|
||||
export const ErrorMessages: Record<ApiErrorCode, string> = {
|
||||
const ErrorMessages: Record<ApiErrorCode, string> = {
|
||||
[ApiErrorCode.NOT_FOUND]: 'The requested resource was not found',
|
||||
[ApiErrorCode.INVALID_INPUT]: 'Invalid input provided',
|
||||
[ApiErrorCode.SESSION_BUSY]: 'Session is currently busy',
|
||||
@@ -687,7 +679,13 @@ export const ErrorMessages: Record<ApiErrorCode, string> = {
|
||||
/**
|
||||
* Hook event types triggered by Claude Code's hooks system
|
||||
*/
|
||||
export type HookEventType = 'idle_prompt' | 'permission_prompt' | 'elicitation_dialog' | 'stop' | 'teammate_idle' | 'task_completed';
|
||||
export type HookEventType =
|
||||
| 'idle_prompt'
|
||||
| 'permission_prompt'
|
||||
| 'elicitation_dialog'
|
||||
| 'stop'
|
||||
| 'teammate_idle'
|
||||
| 'task_completed';
|
||||
|
||||
// ========== API Response Types ==========
|
||||
|
||||
@@ -713,29 +711,6 @@ export function createErrorResponse(code: ApiErrorCode, details?: string): ApiRe
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for session operations
|
||||
*/
|
||||
export interface SessionResponse {
|
||||
/** Whether the request succeeded */
|
||||
success: boolean;
|
||||
/** Session details if successful (light state — no full buffers) */
|
||||
session?: SessionState & {
|
||||
/** Claude session ID from CLI */
|
||||
claudeSessionId: string | null;
|
||||
/** Total API cost */
|
||||
totalCost: number;
|
||||
/** Number of messages */
|
||||
messageCount: number;
|
||||
/** Whether Claude is working */
|
||||
isWorking: boolean;
|
||||
/** Timestamp of last prompt */
|
||||
lastPromptTime: number;
|
||||
};
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for quick start operation
|
||||
*/
|
||||
@@ -842,12 +817,12 @@ export const DEFAULT_CONFIG: AppConfig = {
|
||||
maxConcurrentSessions: 5,
|
||||
stateFilePath: '',
|
||||
respawn: {
|
||||
idleTimeoutMs: 5000, // 5 seconds of no activity after prompt
|
||||
idleTimeoutMs: 5000, // 5 seconds of no activity after prompt
|
||||
updatePrompt: 'update all the docs and CLAUDE.md',
|
||||
interStepDelayMs: 1000, // 1 second between steps
|
||||
enabled: false, // disabled by default
|
||||
sendClear: true, // send /clear after update prompt
|
||||
sendInit: true, // send /init after /clear
|
||||
interStepDelayMs: 1000, // 1 second between steps
|
||||
enabled: false, // disabled by default
|
||||
sendClear: true, // send /clear after update prompt
|
||||
sendInit: true, // send /init after /clear
|
||||
},
|
||||
lastUsedCase: null,
|
||||
ralphEnabled: false,
|
||||
@@ -1117,7 +1092,7 @@ export function createInitialCircuitBreakerStatus(): CircuitBreakerStatus {
|
||||
*/
|
||||
export function createInitialRalphTrackerState(): RalphTrackerState {
|
||||
return {
|
||||
enabled: false, // Disabled by default, auto-enables when Ralph patterns detected
|
||||
enabled: false, // Disabled by default, auto-enables when Ralph patterns detected
|
||||
active: false,
|
||||
completionPhrase: null,
|
||||
startedAt: null,
|
||||
|
||||
@@ -149,7 +149,7 @@ export class BufferAccumulator {
|
||||
* @returns True if buffer ends with the suffix
|
||||
*/
|
||||
endsWith(suffix: string): boolean {
|
||||
if (!suffix) return true; // All strings end with empty string
|
||||
if (!suffix) return true; // All strings end with empty string
|
||||
if (suffix.length > this.totalLength) return false;
|
||||
return this.tail(suffix.length) === suffix;
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function findClaudeDir(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
_claudeDir = ''; // mark as searched, not found
|
||||
_claudeDir = ''; // mark as searched, not found
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -115,11 +115,7 @@ export class CleanupManager implements Disposable {
|
||||
* @param options - Optional configuration
|
||||
* @returns Timer ID for manual clearing if needed
|
||||
*/
|
||||
setTimeout(
|
||||
callback: () => void,
|
||||
delay: number,
|
||||
options?: TimerOptions
|
||||
): string {
|
||||
setTimeout(callback: () => void, delay: number, options?: TimerOptions): string {
|
||||
const id = uuidv4();
|
||||
const timeoutId = setTimeout(() => {
|
||||
// Remove registration when timer fires naturally
|
||||
@@ -148,11 +144,7 @@ export class CleanupManager implements Disposable {
|
||||
* @param options - Optional configuration
|
||||
* @returns Interval ID for manual clearing if needed
|
||||
*/
|
||||
setInterval(
|
||||
callback: () => void,
|
||||
delay: number,
|
||||
options?: TimerOptions
|
||||
): string {
|
||||
setInterval(callback: () => void, delay: number, options?: TimerOptions): string {
|
||||
const id = uuidv4();
|
||||
const intervalId = setInterval(() => {
|
||||
// Don't execute if stopped
|
||||
@@ -179,11 +171,7 @@ export class CleanupManager implements Disposable {
|
||||
* @param description - Human-readable description
|
||||
* @returns Registration ID for manual removal if needed
|
||||
*/
|
||||
registerCleanup(
|
||||
type: CleanupResourceType,
|
||||
cleanup: () => void,
|
||||
description: string
|
||||
): string {
|
||||
registerCleanup(type: CleanupResourceType, cleanup: () => void, description: string): string {
|
||||
const id = uuidv4();
|
||||
this.register({
|
||||
id,
|
||||
@@ -202,10 +190,7 @@ export class CleanupManager implements Disposable {
|
||||
* @param description - Human-readable description
|
||||
* @returns Registration ID
|
||||
*/
|
||||
registerWatcher(
|
||||
watcher: { close: () => void },
|
||||
description: string
|
||||
): string {
|
||||
registerWatcher(watcher: { close: () => void }, description: string): string {
|
||||
return this.registerCleanup('watcher', () => watcher.close(), description);
|
||||
}
|
||||
|
||||
@@ -218,19 +203,23 @@ export class CleanupManager implements Disposable {
|
||||
* @param description - Human-readable description
|
||||
* @returns Registration ID
|
||||
*/
|
||||
registerListener<T extends { removeListener?: (event: string, listener: () => void) => void; off?: (event: string, listener: () => void) => void }>(
|
||||
emitter: T,
|
||||
event: string,
|
||||
listener: () => void,
|
||||
description: string
|
||||
): string {
|
||||
return this.registerCleanup('listener', () => {
|
||||
if (emitter.removeListener) {
|
||||
emitter.removeListener(event, listener);
|
||||
} else if (emitter.off) {
|
||||
emitter.off(event, listener);
|
||||
}
|
||||
}, description);
|
||||
registerListener<
|
||||
T extends {
|
||||
removeListener?: (event: string, listener: () => void) => void;
|
||||
off?: (event: string, listener: () => void) => void;
|
||||
},
|
||||
>(emitter: T, event: string, listener: () => void, description: string): string {
|
||||
return this.registerCleanup(
|
||||
'listener',
|
||||
() => {
|
||||
if (emitter.removeListener) {
|
||||
emitter.removeListener(event, listener);
|
||||
} else if (emitter.off) {
|
||||
emitter.off(event, listener);
|
||||
}
|
||||
},
|
||||
description
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,17 +229,18 @@ export class CleanupManager implements Disposable {
|
||||
* @param description - Human-readable description
|
||||
* @returns Registration ID
|
||||
*/
|
||||
registerStream(
|
||||
stream: { destroy?: () => void; close?: () => void },
|
||||
description: string
|
||||
): string {
|
||||
return this.registerCleanup('stream', () => {
|
||||
if (stream.destroy) {
|
||||
stream.destroy();
|
||||
} else if (stream.close) {
|
||||
stream.close();
|
||||
}
|
||||
}, description);
|
||||
registerStream(stream: { destroy?: () => void; close?: () => void }, description: string): string {
|
||||
return this.registerCleanup(
|
||||
'stream',
|
||||
() => {
|
||||
if (stream.destroy) {
|
||||
stream.destroy();
|
||||
} else if (stream.close) {
|
||||
stream.close();
|
||||
}
|
||||
},
|
||||
description
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,8 +287,10 @@ export class CleanupManager implements Disposable {
|
||||
this.registrations.clear();
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`[CleanupManager] ${errors.length} errors during disposal:`,
|
||||
errors.map(e => e.description).join(', '));
|
||||
console.error(
|
||||
`[CleanupManager] ${errors.length} errors during disposal:`,
|
||||
errors.map((e) => e.description).join(', ')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,22 +15,10 @@ export {
|
||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||
TOKEN_PATTERN,
|
||||
SPINNER_PATTERN,
|
||||
createAnsiPatternFull,
|
||||
createAnsiPatternSimple,
|
||||
stripAnsi,
|
||||
} from './regex-patterns.js';
|
||||
export {
|
||||
MAX_SESSION_TOKENS,
|
||||
validateTokenCounts,
|
||||
validateTokensAndCost,
|
||||
} from './token-validation.js';
|
||||
export {
|
||||
stringSimilarity,
|
||||
normalizePhrase,
|
||||
fuzzyPhraseMatch,
|
||||
todoContentHash,
|
||||
} from './string-similarity.js';
|
||||
export { MAX_SESSION_TOKENS } from './token-validation.js';
|
||||
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
||||
export { assertNever } from './type-safety.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
|
||||
export { resolveOpenCodeDir, isOpenCodeAvailable, getOpenCodeAugmentedPath } from './opencode-cli-resolver.js';
|
||||
export { resolveOpenCodeDir, isOpenCodeAvailable } from './opencode-cli-resolver.js';
|
||||
|
||||